Created
June 25, 2026 17:40
-
-
Save Sleepful/b870a0cdbfaf4590066ffc07a1075220 to your computer and use it in GitHub Desktop.
Per-perspective lazy-load for perspective.el on Emacs daemon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;; ── 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