refactor!(workspaces): replace persp-mode w/ tabspaces

BREAKING CHANGE: As the name says, this is a whole new package. There
will be functional hiccups until I've achieved behavioral parity with
the old workspaces module.
This commit is contained in:
Henrik Lissner 2025-09-14 20:05:28 -04:00
parent 2775cc75f9
commit 243b258f8d
No known key found for this signature in database
GPG key ID: B60957CA074D39A3
6 changed files with 140 additions and 871 deletions

View file

@ -53,7 +53,13 @@ scratch buffer. See `doom-fallback-buffer-name' to change this."
(get-buffer-create doom-fallback-buffer-name)))
;;;###autoload
(defalias 'doom-buffer-list #'buffer-list)
(defun doom-buffer-list (&optional frame)
"Return all buffers in the current workspace.
Same as `buffer-list' if workspaces module isn't enabled."
(if (bound-and-true-p tabspaces-mode)
(tabspaces--buffer-list frame)
(buffer-list frame)))
;;;###autoload
(defun doom-project-buffer-list (&optional project)

View file

@ -4,17 +4,9 @@
#+since: 2.0.0
* Description :unfold:
This module adds support for workspaces, powered by [[doom-package:persp-mode]], as well as a API
for manipulating them.
#+begin_quote
💡 There are many ways to use workspaces. I spawn a workspace per task. Say I'm
working in the main workspace, when I realize there is a bug in another part
of my project. I open a new workspace and deal with it in there. In the
meantime, I need to check my email, so mu4e gets its own workspace.
Once I've completed the task, I close the workspace and return to main.
#+end_quote
This module adds support for workspaces, powered by [[doom-package:tabspaces]] on
top of tab-bar-mode (built into Emacs 27+), as well as a API for manipulating
them.
** Maintainers
- [[doom-user:][@hlissner]]
@ -22,10 +14,11 @@ for manipulating them.
[[doom-contrib-maintainer:][Become a maintainer?]]
** Module flags
/This module has no flags./
- +auto ::
Automatically resume the last saved workspace on startup.
** Packages
- [[doom-package:persp-mode]]
- [[doom-package:tabspaces]]
** TODO Hacks
#+begin_quote
@ -47,8 +40,8 @@ for manipulating them.
#+end_quote
** Isolated buffer-list
When persp-mode is active, ~doom-buffer-list~ becomes workspace-restricted. You
can overcome this by using ~buffer-list~.
When ~tabspaces-mode~ is active, ~doom-buffer-list~ becomes workspace-restricted.
You can overcome this by using ~buffer-list~ instead.
** Automatic workspaces
A workspace is automatically created (and switched to) when you:

View file

@ -0,0 +1,28 @@
;;; ui/workspaces/autoload/compat.el -*- lexical-binding: t; -*-
;;;###if (versionp! emacs-version < "30.1")
;;;###autoload
(defcustom tab-bar-tab-post-select-functions nil
"List of functions to call after selecting a tab.
Two arguments are supplied: the previous tab that was selected before,
and the newly selected tab."
:type '(repeat function)
:group 'tab-bar
:version "30.1")
;;;###autoload
(define-advice tab-bar-select-tab (:around (fn &optional tab-number))
(let* ((tabs (funcall tab-bar-tabs-function))
(from-index (tab-bar--current-tab-index tabs))
(to-number (cond ((< tab-number 0) (+ (length tabs) (1+ tab-number)))
((zerop tab-number) (1+ from-index))
(t tab-number)))
(to-index (1- (max 1 (min to-number (length tabs)))))
(minibuffer-was-active (minibuffer-window-active-p (selected-window)))
(from-tab (tab-bar--tab))
(to-tab (nth to-index tabs)))
(funcall fn tab-number)
(unless (eq from-index to-index)
(run-hook-with-args 'tab-bar-tab-post-select-functions
from-tab to-tab)))

View file

@ -1,646 +1,114 @@
;;; ui/workspaces/autoload/workspaces.el -*- lexical-binding: t; -*-
(defvar +workspace--last nil)
(defvar +workspace--index 0)
;;
;;; Public
;;;###autoload
(defface +workspace-tab-selected-face '((t (:inherit highlight)))
"The face for selected tabs displayed by `+workspace/display'"
:group 'persp-mode)
(cl-defun +workspaces-list (&optional (frames t))
"Return all open tabs in FRAMES (defaults to current frame)."
(let (workspaces)
(dolist (fr (if (eq frames t) (list (selected-frame)) frames))
(dolist (tab (tab-bar-tabs fr))
(push (if (eq (car tab) 'current-tab)
(tab-bar--tab fr)
tab)
workspaces)))
(nreverse workspaces)))
;;;###autoload
(defface +workspace-tab-face '((t (:inherit default)))
"The face for selected tabs displayed by `+workspace/display'"
:group 'persp-mode)
(defun +workspaces-buffer-list (&rest tabs)
"Return a list of buffers associated with TAB."
(seq-filter
#'buffer-live-p
(if (null tabs)
(append (frame-parameter nil 'buffer-list)
(frame-parameter nil 'buried-buffer-list))
(cl-delete-duplicates
(cl-loop for tab in tabs
if (and tab (not (eq (car-safe tab) 'current-tab)))
nconc (or (cdr (assq 'wc-bl tab))
(mapcar #'get-buffer
(car (cdr (assq #'tabspaces--buffer-list
(assq 'ws tab)))))))
:test #'eq))))
;;;###autoload
(defun +workspaces-other-buffer-list ()
"Return a list of buffers in all other workspaces except the current one."
(let ((buffers (buffer-list)))
(dolist (b (+workspaces-buffer-list))
(cl-callf2 delete b buffers))
buffers))
;;;###autoload
(cl-defun +workspaces-contain-buffer-p
(buffer &optional (tab t) (buffer-list (+workspaces-buffer-list tab)))
"Return non-nil if BUFFEr is in TAB."
(memq buffer buffer-list))
;;
;;; Library
(defun +workspace--protected-p (name)
(equal name persp-nil-name))
(defun +workspace--generate-id ()
(or (cl-loop for name in (+workspace-list-names)
when (string-match-p "^#[0-9]+$" name)
maximize (string-to-number (substring name 1)) into max
finally return (if max (1+ max)))
1))
;;; Predicates
;;;###autoload
(defalias #'+workspace-p #'perspective-p
"Return t if OBJ is a perspective hash table.")
;;; Interactive commands
;;;###autoload
(defun +workspace-exists-p (name)
"Returns t if NAME is the name of an existing workspace."
(member name (+workspace-list-names)))
(defalias '+workspaces/new #'tab-bar-new-tab)
;;;###autoload
(defalias #'+workspace-contains-buffer-p #'persp-contain-buffer-p
"Return non-nil if BUFFER is in WORKSPACE (defaults to current workspace).")
;;; Getters
;;;###autoload
(defalias #'+workspace-current #'get-current-persp
"Return the currently active workspace.")
(defalias '+workspaces/new-named #'tabspaces-switch-or-create-workspace)
;;;###autoload
(defun +workspace-get (name &optional noerror)
"Return a workspace named NAME. Unless NOERROR is non-nil, this throws an
error if NAME doesn't exist."
(cl-check-type name string)
(when-let (persp (persp-get-by-name name))
(cond ((+workspace-p persp) persp)
((not noerror)
(error "No workspace called '%s' was found" name)))))
(defalias '+workspaces/save #'tabspaces-save-session)
;;;###autoload
(defun +workspace-current-name ()
"Get the name of the current workspace."
(safe-persp-name (+workspace-current)))
(defalias '+workspaces/load #'tabspaces-restore-session)
;;;###autoload
(defun +workspace-list-names ()
"Return the list of names of open workspaces."
(cl-remove persp-nil-name persp-names-cache :count 1))
;;;###autoload
(defun +workspace-list ()
"Return a list of workspace structs (satisifes `+workspace-p')."
;; We don't use `hash-table-values' because it doesn't ensure order in older
;; versions of Emacs
(cl-loop for name in (+workspace-list-names)
if (gethash name *persp-hash*)
collect it))
;;;###autoload
(defun +workspace-buffer-list (&optional persp)
"Return a list of buffers in PERSP.
PERSP can be a string (name of a workspace) or a workspace (satisfies
`+workspace-p'). If nil or omitted, it defaults to the current workspace."
(let ((persp (or persp (+workspace-current))))
(unless (+workspace-p persp)
(user-error "Not in a valid workspace (%s)" persp))
(persp-buffers persp)))
;;;###autoload
(defun +workspace-orphaned-buffer-list ()
"Return a list of buffers that aren't associated with any perspective."
(cl-remove-if #'persp--buffer-in-persps (buffer-list)))
;;; Actions
;;;###autoload
(defun +workspace-load (name)
"Loads a single workspace (named NAME) into the current session. Can only
retrieve perspectives that were explicitly saved with `+workspace-save'.
Returns t if successful, nil otherwise."
(when (+workspace-exists-p name)
(user-error "A workspace named '%s' already exists." name))
(persp-load-from-file-by-names
(expand-file-name +workspaces-data-file persp-save-dir)
*persp-hash* (list name))
(+workspace-exists-p name))
;;;###autoload
(defun +workspace-save (name)
"Saves a single workspace (NAME) from the current session. Can be loaded again
with `+workspace-load'. NAME can be the string name of a workspace or its
perspective hash table.
Returns t on success, nil otherwise."
(unless (+workspace-exists-p name)
(error "'%s' is an invalid workspace" name))
(let ((fname (expand-file-name +workspaces-data-file persp-save-dir)))
(persp-save-to-file-by-names fname *persp-hash* (list name) t)
(and (member name (persp-list-persp-names-in-file fname))
t)))
;;;###autoload
(defun +workspace-delete (workspace)
"Delete WORKSPACE from the saved workspaces in `persp-save-dir'.
Return t if WORKSPACE was successfully deleted. Throws error if WORKSPACE is not
found or wasn't saved with `+workspace-save'."
(let* ((fname (expand-file-name +workspaces-data-file persp-save-dir))
(workspace-name (if (stringp workspace) workspace (persp-name workspace)))
(workspace-names (persp-list-persp-names-in-file fname))
(workspace-idx (cl-position workspace-name workspace-names :test #'equal)))
(unless workspace-idx
(error "Couldn't find saved workspace '%s'" workspace-name))
(doom-file-write
fname (list (cl-remove-if (lambda (ws) (equal workspace-name (nth 1 ws)))
(doom-file-read fname :by 'read)
:count 1)))
(not (member name (persp-list-persp-names-in-file fname)))))
;;;###autoload
(defun +workspace-new (name)
"Create a new workspace named NAME. If one already exists, return nil.
Otherwise return t on success, nil otherwise."
(when (+workspace--protected-p name)
(error "Can't create a new '%s' workspace" name))
(when (+workspace-exists-p name)
(error "A workspace named '%s' already exists" name))
(let ((persp (persp-add-new name))
(+popup--inhibit-transient t))
(save-window-excursion
(let ((ignore-window-parameters t)
(+popup--inhibit-transient t))
(persp-delete-other-windows))
(switch-to-buffer (doom-fallback-buffer))
(setf (persp-window-conf persp)
(funcall persp-window-state-get-function (selected-frame))))
persp))
;;;###autoload
(defun +workspace-rename (name new-name)
"Rename the current workspace named NAME to NEW-NAME. Returns old name on
success, nil otherwise."
(when (+workspace--protected-p name)
(error "Can't rename '%s' workspace" name))
(persp-rename new-name (+workspace-get name)))
;;;###autoload
(defun +workspace-kill (workspace &optional inhibit-kill-p)
"Kill the workspace denoted by WORKSPACE, which can be the name of a
perspective or its hash table. If INHIBIT-KILL-P is non-nil, don't kill this
workspace's buffers."
(unless (stringp workspace)
(setq workspace (persp-name workspace)))
(when (+workspace--protected-p workspace)
(error "Can't delete '%s' workspace" workspace))
(+workspace-get workspace) ; error checking
(persp-kill workspace inhibit-kill-p)
(not (+workspace-exists-p workspace)))
;;;###autoload
(defun +workspace-switch (name &optional auto-create-p)
"Switch to another workspace named NAME (a string).
If AUTO-CREATE-P is non-nil, create the workspace if it doesn't exist, otherwise
throws an error."
(unless (+workspace-exists-p name)
(if auto-create-p
(+workspace-new name)
(error "%s is not an available workspace" name)))
(let ((old-name (+workspace-current-name)))
(unless (equal old-name name)
(setq +workspace--last
(or (and (not (+workspace--protected-p old-name))
old-name)
+workspaces-main))
(persp-frame-switch name))
(equal (+workspace-current-name) name)))
;;
;;; Commands
;;;###autoload
(defalias '+workspace/restore-last-session #'doom/quickload-session)
;;;###autoload
(defun +workspace/load (name)
"Load a workspace and switch to it. If called with C-u, try to reload the
current workspace (by name) from session files."
(interactive
(list
(if current-prefix-arg
(+workspace-current-name)
(completing-read
"Workspace to load: "
(persp-list-persp-names-in-file
(expand-file-name +workspaces-data-file persp-save-dir))))))
(if (not (+workspace-load name))
(+workspace-error (format "Couldn't load workspace %s" name))
(+workspace/switch-to name)
(+workspace/display)))
;;;###autoload
(defun +workspace/save (name)
"Save the current workspace. If called with C-u, autosave the current
workspace."
(interactive
(list
(if current-prefix-arg
(+workspace-current-name)
(completing-read "Workspace to save: " (+workspace-list-names)))))
(if (+workspace-save name)
(+workspace-message (format "'%s' workspace saved" name) 'success)
(+workspace-error (format "Couldn't save workspace %s" name))))
;;;###autoload
(defun +workspace/rename (new-name)
"Rename the current workspace."
(interactive (list (completing-read "New workspace name: " (list (+workspace-current-name)))))
(condition-case-unless-debug ex
(let* ((current-name (+workspace-current-name))
(old-name (+workspace-rename current-name new-name)))
(unless old-name
(error "Failed to rename %s" current-name))
(+workspace-message (format "Renamed '%s'->'%s'" old-name new-name) 'success))
('error (+workspace-error ex t))))
;;;###autoload
(defun +workspace/kill (name)
"Delete this workspace. If called with C-u, prompts you for the name of the
workspace to delete."
(interactive
(let ((current-name (+workspace-current-name)))
(list
(if current-prefix-arg
(completing-read (format "Kill workspace (default: %s): " current-name)
(+workspace-list-names)
nil nil nil nil current-name)
current-name))))
(condition-case-unless-debug ex
;; REVIEW refactor me
(let ((workspaces (+workspace-list-names)))
(if (not (member name workspaces))
(+workspace-message (format "'%s' workspace doesn't exist" name) 'warn)
(cond ((delq (selected-frame) (persp-frames-with-persp (get-frame-persp)))
(user-error "Can't close workspace, it's visible in another frame"))
((not (equal (+workspace-current-name) name))
(+workspace-kill name))
((cdr workspaces)
(+workspace-kill name)
(+workspace-switch
(if (+workspace-exists-p +workspace--last)
+workspace--last
(car (+workspace-list-names))))
(unless (doom-buffer-frame-predicate (window-buffer))
(switch-to-buffer (doom-fallback-buffer))))
(t
(+workspace-switch +workspaces-main t)
(unless (string= (car workspaces) +workspaces-main)
(+workspace-kill name))
(doom/kill-all-buffers (doom-buffer-list))))
(+workspace-message (format "Deleted '%s' workspace" name) 'success)))
('error (+workspace-error ex t))))
;;;###autoload
(defun +workspace/delete (name)
"Delete a saved workspace in `persp-save-dir'.
Can only selete workspaces saved with `+workspace/save' or `+workspace-save'."
(interactive
(list
(completing-read "Delete saved workspace: "
(cl-loop with wsfile = (doom-path persp-save-dir +workspaces-data-file)
for p in (persp-list-persp-names-in-file wsfile)
collect p))))
(and (condition-case-unless-debug ex
(or (+workspace-delete name)
(+workspace-error (format "Couldn't delete '%s' workspace" name)))
('error (+workspace-error ex t)))
(+workspace-message (format "Deleted '%s' workspace" name) 'success)))
;;;###autoload
(defun +workspace/kill-session (&optional interactive)
"Delete the current session, all workspaces, windows and their buffers."
(interactive (list t))
(let ((windows (length (window-list)))
(persps (length (+workspace-list-names)))
(buffers 0))
(let ((persp-autokill-buffer-on-remove t))
(unless (cl-every #'+workspace-kill (+workspace-list-names))
(+workspace-error "Could not clear session")))
(+workspace-switch +workspaces-main t)
(setq buffers (doom/kill-all-buffers (buffer-list)))
(when interactive
(message "Killed %d workspace(s), %d window(s) & %d buffer(s)"
persps windows buffers))))
;;;###autoload
(defun +workspace/kill-session-and-quit ()
"Kill emacs without saving anything."
(defun +workspaces/kill ()
"Kill all buffers in the workspace and then close the workspace itself."
(interactive)
(let ((persp-auto-save-opt 0))
(kill-emacs)))
(let ((tab-buffers (+workspaces-buffer-list))
(other-buffers (+workspaces-other-buffer-list)))
(unwind-protect
(cl-loop for b in tab-buffers
unless (memq b other-buffers) ; only kill if not open elsewhere
do (kill-buffer b))
(tab-bar-close-tab))))
;;;###autoload
(defun +workspace/new (&optional name clone-p)
"Create a new workspace named NAME. If CLONE-P is non-nil, clone the current
workspace, otherwise the new workspace is blank."
(interactive (list nil current-prefix-arg))
(unless name
(setq name (format "#%s" (+workspace--generate-id))))
(condition-case e
(cond ((+workspace-exists-p name)
(error "%s already exists" name))
(clone-p (persp-copy name t))
(t
(+workspace-switch name t)
(+workspace/display)))
((debug error) (+workspace-error (cadr e) t))))
(defalias '+workspaces/rename #'tab-bar-rename-tab)
;;;###autoload
(defun +workspace/new-named (name)
"Create a new workspace with a given NAME."
(interactive "sWorkspace Name: ")
(+workspace/new name))
(defun +workspaces/kill-session ()
"Delete the current session, all workspaces, windows and their buffers."
(interactive)
(tab-bar-close-other-tabs)
(doom/kill-all-buffers (buffer-list))
(tabspaces-reset-buffer-list)
(switch-to-buffer (doom-fallback-buffer)))
;;;###autoload
(defun +workspace/switch-to (index)
"Switch to a workspace at a given INDEX. A negative number will start from the
end of the workspace list."
(defun +workspaces/switch-to (index-or-name)
"Switch to a workspace at a given INDEX-OR-NAME.
A negative number will start from the end of the workspace list."
(interactive
(list (or current-prefix-arg
(completing-read "Switch to workspace: " (+workspace-list-names)))))
(when (and (stringp index)
(string-match-p "^[0-9]+$" index))
(setq index (string-to-number index)))
(condition-case-unless-debug ex
(let ((names (+workspace-list-names))
(old-name (+workspace-current-name)))
(cond ((numberp index)
(let ((dest (nth index names)))
(unless dest
(error "No workspace at #%s" (1+ index)))
(+workspace-switch dest)))
((stringp index)
(+workspace-switch index t))
(t
(error "Not a valid index: %s" index)))
(unless (called-interactively-p 'interactive)
(if (equal (+workspace-current-name) old-name)
(+workspace-message (format "Already in %s" old-name) 'warn)
(+workspace/display))))
('error (+workspace-error (cadr ex) t))))
(completing-read "Switch to workspace: " (tabspaces--list-tabspaces)))))
(if (numberp index-or-name)
(tab-bar-select-tab (1+ index-or-name))
(tabspaces-switch-or-create-workspace
(or (cl-loop for tab in (tab-bar-tabs)
for name = (alist-get 'name tab)
if (equal name index-or-name)
return name)
(user-error "No workspace with name: %s" name)))))
;;;###autoload
(dotimes (i 9)
(defalias (intern (format "+workspace/switch-to-%d" i))
(lambda () (interactive) (+workspace/switch-to i))
(format "Switch to workspace #%d" (1+ i))))
(defalias (intern (format "+workspaces/switch-to-%d" i))
(cmd! (+workspaces/switch-to i))))
;;;###autoload
(defun +workspace/switch-to-final ()
"Switch to the final workspace in open workspaces."
(interactive)
(+workspace/switch-to (car (last (+workspace-list-names)))))
(defalias '+workspaces/switch-to-final #'tab-bar-switch-to-last-tab)
;;;###autoload
(defun +workspace/other ()
"Switch to the last activated workspace."
(interactive)
(+workspace/switch-to +workspace--last))
;;;###autoload
(defun +workspace/cycle (n)
"Cycle n workspaces to the right (default) or left."
(interactive (list 1))
(let ((current-name (+workspace-current-name)))
(if (+workspace--protected-p current-name)
(+workspace-switch +workspaces-main t)
(condition-case-unless-debug ex
(let* ((persps (+workspace-list-names))
(perspc (length persps))
(index (cl-position current-name persps)))
(when (= perspc 1)
(user-error "No other workspaces"))
(+workspace/switch-to (% (+ index n perspc) perspc))
(unless (called-interactively-p 'interactive)
(+workspace/display)))
('user-error (+workspace-error (cadr ex) t))
('error (+workspace-error ex t))))))
;;;###autoload
(defun +workspace/switch-left (&optional n) (interactive "p") (+workspace/cycle (- n)))
;;;###autoload
(defun +workspace/switch-right (&optional n) (interactive "p") (+workspace/cycle n))
;;;###autoload
(defun +workspace/close-window-or-workspace ()
"Close the selected window. If it's the last window in the workspace, either
close the workspace (as well as its associated frame, if one exists) and move to
the next."
(interactive)
(let ((delete-window-fn (if (featurep 'evil) #'evil-window-delete #'delete-window)))
(if (window-dedicated-p)
(funcall delete-window-fn)
(let ((current-persp-name (+workspace-current-name)))
(cond ((or (+workspace--protected-p current-persp-name)
(cdr (doom-visible-windows)))
(funcall delete-window-fn))
((cdr (+workspace-list-names))
(let ((frame-persp (frame-parameter nil 'workspace)))
(if (string= frame-persp (+workspace-current-name))
(delete-frame)
(+workspace/kill current-persp-name))))
((+workspace-error "Can't delete last workspace" t)))))))
;;;###autoload
(defun +workspace/swap-left (&optional count)
"Swap the current workspace with the COUNTth workspace on its left."
(interactive "p")
(let* ((current-name (+workspace-current-name))
(count (or count 1))
(persps (+workspace-list-names))
(index (- (cl-position current-name persps :test #'equal)
count))
(names (remove current-name persps)))
(unless names
(user-error "Only one workspace"))
(let ((index (min (max 0 index) (length names))))
(setq persp-names-cache
(append (cl-subseq names 0 index)
(list current-name)
(cl-subseq names index))))
(when (called-interactively-p 'any)
(+workspace/display))))
;;;###autoload
(defun +workspace/swap-right (&optional count)
"Swap the current workspace with the COUNTth workspace on its right."
(interactive "p")
(funcall-interactively #'+workspace/swap-left (- count)))
;;
;;; Tabs display in minibuffer
(defun +workspace--tabline (&optional names)
(let ((names (or names (+workspace-list-names)))
(current-name (+workspace-current-name)))
(mapconcat
#'identity
(cl-loop for name in names
for i to (length names)
collect
(propertize (format " [%d] %s " (1+ i) name)
'face (if (equal current-name name)
'+workspace-tab-selected-face
'+workspace-tab-face)))
" ")))
(defun +workspace--message-body (message &optional type)
(concat (+workspace--tabline)
(propertize " | " 'face 'font-lock-comment-face)
(propertize (format "%s" message)
'face (pcase type
('error 'error)
('warn 'warning)
('success 'success)
('info 'font-lock-comment-face)))))
;;;###autoload
(defun +workspace-message (message &optional type)
"Show an 'elegant' message in the echo area next to a listing of workspaces."
(message "%s" (+workspace--message-body message type)))
;;;###autoload
(defun +workspace-error (message &optional noerror)
"Show an 'elegant' error in the echo area next to a listing of workspaces."
(funcall (if noerror #'message #'error)
"%s" (+workspace--message-body message 'error)))
;;;###autoload
(defun +workspace/display ()
"Display a list of workspaces (like tabs) in the echo area."
(interactive)
(let (message-log-max)
(message "%s" (+workspace--tabline))))
;;
;;; Hooks
;;;###autoload
(defun +workspaces-delete-associated-workspace-h (&optional frame)
"Delete workspace associated with current frame.
A workspace gets associated with a frame when a new frame is interactively
created."
(when (and persp-mode (not (bound-and-true-p with-editor-mode)))
(unless frame
(setq frame (selected-frame)))
(let ((frame-persp (frame-parameter frame 'workspace)))
(when (string= frame-persp (+workspace-current-name))
(+workspace/kill frame-persp)))))
;;;###autoload
(defun +workspaces-associate-frame-fn (frame &optional _new-frame-p)
"Create a blank, new perspective and associate it with FRAME."
(when persp-mode
(with-selected-frame frame
(if (not (cdr-safe (persp-frame-list-without-daemon)))
(+workspace-switch +workspaces-main t)
(+workspace-switch (format "#%s" (+workspace--generate-id)) t))
(unless (doom-real-buffer-p (current-buffer))
(switch-to-buffer (doom-fallback-buffer)))
(set-frame-parameter frame 'workspace (+workspace-current-name))
;; ensure every buffer has a buffer-predicate
(persp-set-frame-buffer-predicate frame))
(run-at-time 0.1 nil #'+workspace/display)))
;;;###autoload
(defun +workspaces-switch-to-project-h (&optional dir)
"Creates a workspace dedicated to a new project. If one already exists, switch
to it. If in the main workspace and it's empty, recycle that workspace, without
renaming it.
Afterwords, runs `+workspaces-switch-project-function'. By default, this prompts
the user to open a file in the new project.
This be hooked to `projectile-after-switch-project-hook'."
(let* ((default-directory (or dir default-directory))
(pname (doom-project-name))
(proot (file-truename default-directory))
;; HACK: Clear projectile-project-root or cached roots could interfere
;; with project switching (see #3166).
projectile-project-root)
(when persp-mode
(if (and (not (null +workspaces-on-switch-project-behavior))
(or (eq +workspaces-on-switch-project-behavior t)
(+workspace--protected-p (safe-persp-name (get-current-persp)))
(+workspace-buffer-list)))
(let* ((ws-param '+workspace-project)
(ws (+workspace-get pname t))
(ws (if (and ws
(ignore-errors
(file-equal-p (persp-parameter ws-param ws)
proot)))
ws
;; Uniquify the project's name, so we don't clobber a
;; pre-existing workspace with the same name.
(let* ((parts (nreverse (split-string proot "/" t)))
(pre (cdr parts))
(post (list (car parts))))
(while (and pre
(setq ws (+workspace-get (setq pname (string-join post "/")) t))
(not (ignore-errors
(file-equal-p (persp-parameter ws-param ws)
proot))))
(push (pop pre) post))
(unless pre ws))))
(ws (or ws
(+workspace-get pname t)
(+workspace-new pname))))
(set-persp-parameter ws-param proot ws)
(+workspace-switch pname)
(with-current-buffer (doom-fallback-buffer)
(setq-local default-directory proot)
(hack-dir-local-variables-non-file-buffer))
(unless current-prefix-arg
(funcall +workspaces-switch-project-function proot))
(+workspace-message
(format "Switched to '%s' in new workspace" pname)
'success))
(with-current-buffer (doom-fallback-buffer)
(setq-local default-directory proot)
(hack-dir-local-variables-non-file-buffer)
(message "Switched to '%s'" pname))
(with-demoted-errors "Workspace error: %s"
(+workspace-rename (+workspace-current-name) pname))
(unless current-prefix-arg
(funcall +workspaces-switch-project-function proot))))))
;;;###autoload
(defun +workspaces-save-tab-bar-data-h (&rest _)
"Save the current workspace's tab bar data."
(when (get-current-persp)
(set-persp-parameter
'tab-bar-tabs (tab-bar-tabs))
(set-persp-parameter 'tab-bar-closed-tabs tab-bar-closed-tabs)))
;;;###autoload
(defun +workspaces-save-tab-bar-data-to-file-h (&rest _)
"Save the current workspace's tab bar data to file."
(when (get-current-persp)
;; HACK: Remove fields (for window-configuration) that cannot be serialized.
(set-persp-parameter 'tab-bar-tabs
(frameset-filter-tabs (tab-bar-tabs) nil nil t))))
;;;###autoload
(defun +workspaces-load-tab-bar-data-h (&rest _)
"Restores the tab bar data of the workspace we have just switched to."
(tab-bar-tabs-set (persp-parameter 'tab-bar-tabs))
(setq tab-bar-closed-tabs (persp-parameter 'tab-bar-closed-tabs))
(tab-bar--update-tab-bar-lines t))
;;;###autoload
(defun +workspaces-load-tab-bar-data-from-file-h (&rest _)
"Restores the tab bar data from file."
(when-let ((persp-tab-data (persp-parameter 'tab-bar-tabs)))
(tab-bar-tabs-set persp-tab-data)
(tab-bar--update-tab-bar-lines t)))
;;
;;; Advice
;;;###autoload
(defun +workspaces-autosave-real-buffers-a (fn &rest args)
"Don't autosave if no real buffers are open."
(when (doom-real-buffer-list)
(apply fn args))
t)
;;; workspaces.el ends here

View file

@ -1,261 +1,35 @@
;;; ui/workspaces/config.el -*- lexical-binding: t; -*-
;; `persp-mode' gives me workspaces, a workspace-restricted `buffer-list', and
;; file-based session persistence. I used workgroups2 before this, but abandoned
;; it because it was unstable and slow; `persp-mode' is neither (and still
;; maintained).
;;
;; NOTE persp-mode requires `workgroups' for file persistence in Emacs 24.4.
(use-package! tabspaces
:hook (doom-init-ui . tabspaces-mode)
:init
(setq tabspaces-session-file (file-name-concat doom-profile-data-dir "workspaces.el")
tab-bar-show 1) ; hide if only one workspace
(defvar +workspaces-main "main"
"The name of the primary and initial workspace, which cannot be deleted.")
(defvar +workspaces-switch-project-function #'doom-project-find-file
"The function to run after `projectile-switch-project' or
`counsel-projectile-switch-project'. This function must take one argument: the
new project directory.")
(defvar +workspaces-on-switch-project-behavior 'non-empty
"Controls the behavior of workspaces when switching to a new project.
Can be one of the following:
t Always create a new workspace for the project
'non-empty Only create a new workspace if the current one already has buffers
associated with it.
nil Never create a new workspace on project switch.")
;; FIXME actually use this for wconf bookmark system
(defvar +workspaces-data-file "_workspaces"
"The basename of the file to store single workspace perspectives. Will be
stored in `persp-save-dir'.")
(defvar +workspace--old-uniquify-style nil)
;;
;; Packages
(use-package! persp-mode
:unless noninteractive
:commands persp-switch-to-buffer
:hook (doom-init-ui . persp-mode)
:config
(setq persp-autokill-buffer-on-remove 'kill-weak
persp-reset-windows-on-nil-window-conf nil
persp-nil-hidden t
persp-auto-save-fname "autosave"
persp-save-dir (concat doom-data-dir "workspaces/")
persp-set-last-persp-for-new-frames t
persp-switch-to-added-buffer nil
persp-kill-foreign-buffer-behaviour 'kill
persp-remove-buffers-from-nil-persp-behaviour nil
persp-auto-resume-time -1 ; Don't auto-load on startup
persp-auto-save-opt (if noninteractive 0 1)) ; auto-save on kill
(setq tabspaces-session t
tabspaces-session-auto-restore (modulep! +auto)
tabspaces-use-filtered-buffers-as-default t
tabspaces-default-tab "Main"
tabspaces-remove-to-default nil)
;;;; Create main workspace
;; The default perspective persp-mode creates is special and doesn't represent
;; a real persp object, so buffers can't really be assigned to it, among other
;; quirks, so I replace it with a "main" perspective.
(add-hook! '(persp-mode-hook persp-after-load-state-functions)
(defun +workspaces-ensure-no-nil-workspaces-h (&rest _)
(when persp-mode
(dolist (frame (frame-list))
(when (string= (safe-persp-name (get-current-persp frame)) persp-nil-name)
;; Take extra steps to ensure no frame ends up in the nil perspective
(persp-frame-switch (or (cadr (hash-table-keys *persp-hash*))
+workspaces-main)
frame))))))
(add-hook! 'persp-mode-hook
(defun +workspaces-init-first-workspace-h (&rest _)
"Ensure a main workspace exists."
(when persp-mode
(let (persp-before-switch-functions)
(unless (or (persp-get-by-name +workspaces-main)
;; Start from 2 b/c persp-mode counts the nil workspace
(> (hash-table-count *persp-hash*) 2))
(persp-add-new +workspaces-main))
;; HACK Fix #319: the warnings buffer gets swallowed when creating
;; `+workspaces-main', so display it ourselves, if it exists.
(when-let (warnings (get-buffer "*Warnings*"))
(unless (get-buffer-window warnings)
(save-excursion
(display-buffer-in-side-window
warnings '((window-height . shrink-window-if-larger-than-buffer)))))))))
(defun +workspaces-init-persp-mode-h ()
(cond (persp-mode
;; `uniquify' breaks persp-mode. It renames old buffers, which causes
;; errors when switching between perspective (their buffers are
;; serialized by name and persp-mode expects them to have the same
;; name when restored).
(when uniquify-buffer-name-style
(setq +workspace--old-uniquify-style uniquify-buffer-name-style))
(setq uniquify-buffer-name-style nil)
;; Ensure `persp-kill-buffer-query-function' is last
(remove-hook 'kill-buffer-query-functions #'persp-kill-buffer-query-function)
(add-hook 'kill-buffer-query-functions #'persp-kill-buffer-query-function t)
;; Restrict buffer list to workspace
(advice-add #'doom-buffer-list :override #'+workspace-buffer-list))
(t
(when +workspace--old-uniquify-style
(setq uniquify-buffer-name-style +workspace--old-uniquify-style))
(advice-remove #'doom-buffer-list #'+workspace-buffer-list)))))
;; Delete the current workspace if closing the last open window
;; (define-key! persp-mode-map
;; [remap delete-window] #'+workspace/close-window-or-workspace
;; [remap evil-window-delete] #'+workspace/close-window-or-workspace)
;; Per-workspace `winner-mode' history
(add-to-list 'window-persistent-parameters '(winner-ring . t))
(add-hook! 'persp-before-deactivate-functions
(defun +workspaces-save-winner-data-h (&rest _)
(when (and (bound-and-true-p winner-mode)
(get-current-persp))
(set-persp-parameter
'winner-ring (list winner-currents
winner-ring-alist
winner-pending-undo-ring)))))
(add-hook! 'persp-activated-functions
(defun +workspaces-load-winner-data-h (&rest _)
(when (bound-and-true-p winner-mode)
(cl-destructuring-bind
(currents alist pending-undo-ring)
(or (persp-parameter 'winner-ring) (list nil nil nil))
(setq winner-undo-frame nil
winner-currents currents
winner-ring-alist alist
winner-pending-undo-ring pending-undo-ring)))))
;;;; Registering buffers to perspectives
(add-hook! 'doom-switch-buffer-hook
(defun +workspaces-add-current-buffer-h ()
"Add current buffer to focused perspective."
(or (not persp-mode)
(persp-buffer-filtered-out-p
(or (buffer-base-buffer (current-buffer))
(current-buffer))
persp-add-buffer-on-after-change-major-mode-filter-functions)
(persp-add-buffer (current-buffer) (get-current-persp) nil nil))))
(add-hook 'persp-add-buffer-on-after-change-major-mode-filter-functions
#'doom-unreal-buffer-p)
(defadvice! +workspaces--evil-alternate-buffer-a (&optional window)
"Make `evil-alternate-buffer' ignore buffers outside the current workspace."
:override #'evil-alternate-buffer
(let* ((prev-buffers
(if persp-mode
(cl-remove-if-not #'persp-contain-buffer-p (window-prev-buffers)
(cl-remove-if-not #'tabspaces--local-buffer-p (window-prev-buffers)
:key #'car)
(window-prev-buffers)))
(head (car prev-buffers)))
(if (eq (car head) (window-buffer window))
(cadr prev-buffers)
head)))
;; HACK Fixes #4196, #1525: selecting deleted buffer error when quitting Emacs
;; or on some buffer listing ops.
(defadvice! +workspaces-remove-dead-buffers-a (persp)
:before #'persp-buffers-to-savelist
(when (perspective-p persp)
;; HACK Can't use `persp-buffers' because of a race condition with its gv
;; getter/setter not being defined in time.
(setf (aref persp 2)
(cl-delete-if-not #'persp-get-buffer-or-null (persp-buffers persp)))))
;; Delete the current workspace if closing the last open window
(define-key! persp-mode-map
[remap delete-window] #'+workspace/close-window-or-workspace
[remap evil-window-delete] #'+workspace/close-window-or-workspace)
;; per-frame workspaces
(setq persp-init-frame-behaviour t
persp-init-new-frame-behaviour-override nil
persp-interactive-init-frame-behaviour-override #'+workspaces-associate-frame-fn
persp-emacsclient-init-frame-behaviour-override #'+workspaces-associate-frame-fn)
(add-hook 'delete-frame-functions #'+workspaces-delete-associated-workspace-h)
(add-hook 'server-done-hook #'+workspaces-delete-associated-workspace-h)
;; per-project workspaces, but reuse current workspace if empty
;; HACK?? needs review
(setq projectile-switch-project-action #'+workspaces-switch-to-project-h
counsel-projectile-switch-project-action
'(1 ("o" +workspaces-switch-to-project-h "open project in new workspace")
("O" counsel-projectile-switch-project-action "jump to a project buffer or file")
("f" counsel-projectile-switch-project-action-find-file "jump to a project file")
("d" counsel-projectile-switch-project-action-find-dir "jump to a project directory")
("D" counsel-projectile-switch-project-action-dired "open project in dired")
("b" counsel-projectile-switch-project-action-switch-to-buffer "jump to a project buffer")
("m" counsel-projectile-switch-project-action-find-file-manually "find file manually from project root")
("w" counsel-projectile-switch-project-action-save-all-buffers "save all project buffers")
("k" counsel-projectile-switch-project-action-kill-buffers "kill all project buffers")
("r" counsel-projectile-switch-project-action-remove-known-project "remove project from known projects")
("c" counsel-projectile-switch-project-action-compile "run project compilation command")
("C" counsel-projectile-switch-project-action-configure "run project configure command")
("e" counsel-projectile-switch-project-action-edit-dir-locals "edit project dir-locals")
("v" counsel-projectile-switch-project-action-vc "open project in vc-dir / magit / monky")
("s" (lambda (project)
(let ((projectile-switch-project-action
(lambda () (call-interactively #'+ivy/project-search))))
(counsel-projectile-switch-project-by-name project))) "search project")
("xs" counsel-projectile-switch-project-action-run-shell "invoke shell from project root")
("xe" counsel-projectile-switch-project-action-run-eshell "invoke eshell from project root")
("xt" counsel-projectile-switch-project-action-run-term "invoke term from project root")
("X" counsel-projectile-switch-project-action-org-capture "org-capture into project")))
(when (modulep! :completion helm)
(after! helm-projectile
(setcar helm-source-projectile-projects-actions
'("Switch to Project" . +workspaces-switch-to-project-h))))
;; Don't bother auto-saving the session if no real buffers are open.
(advice-add #'persp-asave-on-exit :around #'+workspaces-autosave-real-buffers-a)
;; Fix #1973: visual selection surviving workspace changes
(add-hook 'persp-before-deactivate-functions #'deactivate-mark)
;; Fix #1017: stop session persistence from restoring a broken posframe
(after! posframe
(add-hook! 'persp-after-load-state-functions
(defun +workspaces-delete-all-posframes-h (&rest _)
(posframe-delete-all))))
;; Don't try to persist dead/remote buffers. They cause errors.
(add-hook! 'persp-filter-save-buffers-functions
(defun +workspaces-dead-buffer-p (buf)
;; Fix #1525: Ignore dead buffers in PERSP's buffer list
(not (buffer-live-p buf)))
(defun +workspaces-remote-buffer-p (buf)
;; And don't save TRAMP buffers; they're super slow to restore
(let ((dir (buffer-local-value 'default-directory buf)))
(ignore-errors (file-remote-p dir)))))
;; Otherwise, buffers opened via bookmarks aren't treated as "real" and are
;; excluded from the buffer list.
(add-hook 'bookmark-after-jump-hook #'+workspaces-add-current-buffer-h)
;;; eshell
(persp-def-buffer-save/load
:mode 'eshell-mode :tag-symbol 'def-eshell-buffer
:save-vars '(major-mode default-directory))
;; compile
(persp-def-buffer-save/load
:mode 'compilation-mode :tag-symbol 'def-compilation-buffer
:save-vars '(major-mode default-directory compilation-directory
compilation-environment compilation-arguments))
;; magit
(persp-def-buffer-save/load
:mode 'magit-status-mode :tag-symbol 'def-magit-status-buffer
:save-vars '(default-directory)
:load-function (lambda (savelist &rest _)
(cl-destructuring-bind (buffer-name vars &rest _rest) (cdr savelist)
(magit-status (alist-get 'default-directory vars)))))
;;; tab-bar
(add-hook! 'tab-bar-mode-hook
(defun +workspaces-set-up-tab-bar-integration-h ()
(add-hook 'persp-before-deactivate-functions #'+workspaces-save-tab-bar-data-h)
(add-hook 'persp-activated-functions #'+workspaces-load-tab-bar-data-h)
;; Load and save configurations for tab-bar.
(add-hook 'persp-before-save-state-to-file-functions #'+workspaces-save-tab-bar-data-to-file-h)
(+workspaces-load-tab-bar-data-from-file-h))))
head))))

View file

@ -1,4 +1,4 @@
;; -*- no-byte-compile: t; -*-
;;; ui/workspaces/packages.el
(package! persp-mode :pin "40e9993a9711cba5fb56dfec81a507fabeba9668")
(package! tabspaces :pin "6c7c31b7442b04e6369d33b2ccfe2045e631b374")