Skip to content

Instantly share code, notes, and snippets.

@Sleepful
Created June 25, 2026 17:40
Show Gist options
  • Select an option

  • Save Sleepful/b870a0cdbfaf4590066ffc07a1075220 to your computer and use it in GitHub Desktop.

Select an option

Save Sleepful/b870a0cdbfaf4590066ffc07a1075220 to your computer and use it in GitHub Desktop.
Per-perspective lazy-load for perspective.el on Emacs daemon
;; ── Per-perspective state files for Emacs daemon mode ──────────────
;; Requires: perspective, eglot
;;
;; Saves each perspective as var/perspectives/<name>.el.
;; Buffers open lazily — only the perspective you switch into loads files.
;; Unloaded perspectives appear dimmed in dashboard and load on click.
;; Works around perspectives not supporting daemon mode (issues #41, #194).
;; Tested on Emacs 30.2 + daemon + vtsls LSP.
;;
;; ── Key design decisions ────────────────────────────────────────────
;;
;; 1. Save on switch-away (:before advice on persp-switch)
;; Saves the outgoing perspective while it's still current.
;;
;; 2. Load switches first, then opens files
;; find-file must run AFTER persp-switch so buffers enter the right
;; perspective. Otherwise they leak into whatever perspective was
;; current before the switch.
;;
;; 3. my-persp-saving guard prevents double-saves
;; with-perspective calls persp-switch internally, which would
;; re-trigger the :before advice. Bind my-persp-saving to t
;; during save-all and load to suppress recursive saves.
;;
;; 4. Non-current perspectives read struct data directly
;; my-persp-save-all cannot use with-perspective (it imports buffers
;; into wrong perspective on switch-back). Read persp-buffers and
;; persp-point-marker from the struct directly.
;;
;; 5. (windows nil) for non-current perspectives
;; persp-window-configuration returns #<window-configuration>, which
;; prin1 cannot serialize. Current perspective uses window-state-get
;; instead (produces a readable list).
;;
;; 6. Terminal frame hash stripped on init
;; Daemon creates two frames (terminal + GUI). persp--state-frame-data
;; saves both. On load, Frame-1 (terminal data) overwrites the GUI
;; frame. Strip persp--hash from non-GUI frames in after-init-hook.
;;
;; ── Dashboard integration ──────────────────────────────────────────
;;
;; Add a perspectives section to dashboard that:
;; - Lists all perspectives (live + unloaded from disk)
;; - Dims unloaded perspectives with the 'shadow face
;; - Opens unloaded perspectives with my-persp-switch (lazy-load)
;; - Opens loaded perspectives with persp-switch (direct)
;;
;; Register a generator in dashboard-item-generators and a shortcut in
;; dashboard-item-shortcuts.
;; ── Directory and helper ────────────────────────────────────────
(defvar my-persp-dir
(expand-file-name "var/perspectives" user-emacs-directory))
(defun my-persp-file (name)
"State file path for perspective NAME."
(expand-file-name (concat name ".el") my-persp-dir))
;; ── Save (per-switch + per-perspective) ─────────────────────────
(defvar my-persp-saving nil
"Guard flag to prevent recursive saves.")
(defun my-persp-save (&optional name)
"Save perspective NAME to var/perspectives/<name>.el.
Assumes caller is already in the target perspective (the :before
advice fires before persp-switch changes context)."
(let* ((name (or name (persp-current-name)))
(file (my-persp-file name))
(persp (gethash name (perspectives-hash))))
(when (and persp (not (persp-killed-p persp)))
(persp-save)
(make-directory my-persp-dir t)
(let ((data `(persp-state
(buffers
,(cl-loop for b in (persp-current-buffers)
when (buffer-file-name b)
collect (buffer-file-name b)))
(windows
,(window-state-get (frame-root-window) t))
(point ,(point)))))
(with-temp-file file
(prin1 data (current-buffer)))))))
;; ── Load (lazy, one perspective at a time) ──────────────────────
(defun my-persp-load (name)
"Load perspective NAME from its state file into the current frame."
(let ((file (my-persp-file name)))
(when (file-exists-p file)
(let* ((data (with-temp-buffer
(insert-file-contents file)
(read (current-buffer))))
(buffers (cadr (assq 'buffers data)))
(windows (cadr (assq 'windows data)))
(point-pos (cadr (assq 'point data)))
(eglot-server-programs nil)
(my-eglot-suppressed t))
;; Switch to perspective FIRST so files open into it
(unless (gethash name (perspectives-hash))
(persp-switch name))
;; Open files — they go into the target perspective
(dolist (f buffers)
(when (file-exists-p f)
(find-file f)))
;; Ensure all opened buffers are in the perspective
(dolist (b buffers)
(when-let ((buf (get-file-buffer b)))
(unless (persp-is-current-buffer buf)
(persp-add-buffer buf))))
;; Restore window layout
(when windows
(ignore-errors
(window-state-put windows (frame-root-window) 'safe)))
;; Restore point
(ignore-errors
(goto-char point-pos))))
t)))
;; ── Save-all (kill-emacs hook) ──────────────────────────────────
(defun my-persp-save-all ()
"Save all live perspectives to their per-perspective files."
(let ((my-persp-saving t))
(dolist (name (hash-table-keys (perspectives-hash)))
(if (equal name (persp-current-name))
(ignore-errors (my-persp-save name))
;; Non-current: read struct directly — no context switch
(let* ((persp (gethash name (perspectives-hash)))
(file (my-persp-file name)))
(when (and persp (not (persp-killed-p persp)))
(make-directory my-persp-dir t)
(let* ((buffers (cl-loop for b in (persp-buffers persp)
when (and (buffer-live-p b)
(buffer-file-name b))
collect (buffer-file-name b)))
(point (when-let ((m (persp-point-marker persp)))
(and (marker-position m)
(marker-position m))))
(data `(persp-state (buffers ,buffers)
(windows nil) ;; non-current: can't serialize
(point ,point))))
(with-temp-file file
(prin1 data (current-buffer))))))))))
;; ── Lazy switch (loads from disk if not yet in hash) ────────────
(defun my-persp-switch (name)
"Switch to perspective NAME, loading from disk if not yet loaded."
(interactive
(list (completing-read "Perspective: " (my-persp-names) nil nil)))
(unless (my-persp-loaded-p name)
(message "Loading perspective %s..." name)
(let ((my-persp-saving t))
(my-persp-load name)))
(persp-switch name))
;; ── Name listing (live hash + disk files) ───────────────────────
(defun my-persp-names ()
"Return all known perspective names (live + on-disk)."
(delete-dups
(append (hash-table-keys (perspectives-hash))
(when (file-directory-p my-persp-dir)
(mapcar (lambda (f) (file-name-base f))
(directory-files my-persp-dir nil "\\.el\\'"))))))
(defun my-persp-loaded-p (name)
"Return t if perspective NAME is currently loaded."
(and (gethash name (perspectives-hash)) t))
;; ── Hooks ───────────────────────────────────────────────────────
;; Save outgoing perspective before every switch
(advice-add 'persp-switch :before
(lambda (name &rest _)
(unless my-persp-saving
(when-let ((old-name (persp-current-name)))
(unless (equal old-name name)
(let ((my-persp-saving t))
(ignore-errors (my-persp-save old-name))))))))
;; Save all perspectives on kill-emacs
(add-hook 'kill-emacs-hook #'my-persp-save-all)
;; Delete state file when perspective is killed
(add-hook 'persp-killed-hook
(lambda ()
(ignore-errors
(delete-file (my-persp-file (persp-current-name))))))
;; Don't import buffers when switching perspectives
(setq persp-switch-to-buffer-behavior nil)
;; ── Daemon: strip terminal frame hash ───────────────────────────
(add-hook 'after-init-hook
(lambda ()
(dolist (frame (frame-list))
(unless (display-graphic-p frame)
(set-frame-parameter frame 'persp--hash nil)
(set-frame-parameter frame 'persp--curr nil)))))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment