;;; init-project.el --- Project switching, scoped to ~/Projects -*- lexical-binding: t; -*- ;;; Commentary: ;; Built on built-in project.el, no third-party manager. Projects are the ;; direct children of ~/Projects, rescanned authoritatively each startup so ;; the list never accumulates stale entries. A "pin" keeps the current ;; project from hijacking to a dependency's checkout on jump-to-definition. ;;; Code: (require 'project) ;; A directory is a project root if under VC or containing one of these. (setq project-vc-extra-root-markers '("build.sh")) (defvar my/project-search-dirs '("~/Projects") "Parent directories scanned for known projects.") ;;;; Project list: rebuilt from a fresh scan each startup ;; project-remember-project would APPEND to the persisted list and leak stale ;; entries (old paths, stdlib dirs, the config repo). Instead scan ~/Projects ;; and assign project--list directly; a real list also stops project.el ;; re-reading the stale file this session. (defun my/scan-projects-under (parent) "Return `project--list' entries for direct child projects of PARENT. Ignore symlinks; don't recurse." (when (file-directory-p parent) (let (entries) (dolist (dir (directory-files parent t directory-files-no-dot-files-regexp)) (when (and (file-directory-p dir) (not (file-symlink-p dir))) (let ((default-directory dir)) (when-let ((project (project-current nil))) (push (list (abbreviate-file-name (project-root project))) entries))))) entries))) (setq project--list (sort (mapcan #'my/scan-projects-under (mapcar #'expand-file-name my/project-search-dirs)) (lambda (a b) (time-less-p (file-attribute-modification-time (file-attributes (car b))) (file-attribute-modification-time (file-attributes (car a))))))) ;; Persist so the on-disk file matches (no stale artifact left behind). (project--write-project-list) ;;;; Sticky project detection ;; project.el recomputes the project from the visited buffer's directory, so ;; jumping into a dependency's VC checkout (via `gd') would make THAT the ;; project. Instead pin the last project under our roots and reuse it for any ;; buffer outside them. (defvar my/pinned-project nil "Last project resolved from a buffer under `my/project-search-dirs'.") (defun my/own-project-dir-p (dir) "Return non-nil when DIR lives under one of `my/project-search-dirs'." (and dir (seq-some (lambda (parent) (file-in-directory-p dir (expand-file-name parent))) my/project-search-dirs))) (defun my/project-pinned (dir) "Resolve DIR to the pinned project when DIR is outside our roots. Runs ahead of `project-try-vc', so foreign buffers never become current." (and my/pinned-project (not (my/own-project-dir-p dir)) my/pinned-project)) (defun my/remember-pinned-project (project) "Record PROJECT as the pin when the current buffer is one of ours." (when (and project (my/own-project-dir-p default-directory)) (setq my/pinned-project project)) project) (add-hook 'project-find-functions #'my/project-pinned) ; before project-try-vc (advice-add 'project-current :filter-return #'my/remember-pinned-project) ;;;; Switching and cleanup ;; Skip the "what do you want to do" menu entirely. (setq project-switch-commands #'project-dired) ;; Widen what counts as "belongs to this project" for cleanup. (setq project-kill-buffer-conditions '(buffer-file-name (major-mode . compilation-mode) (major-mode . dired-mode) (major-mode . shell-mode) (major-mode . eshell-mode) (major-mode . term-mode))) (defun my/project-switch-fresh () "Save and close the current project before switching." (interactive) (when (project-current nil) (save-some-buffers t) (project-kill-buffers t)) (call-interactively #'project-switch-project)) (global-set-key (kbd "C-c C-p") #'my/project-switch-fresh) (with-eval-after-load 'which-key (which-key-add-key-based-replacements "C-c C-p" "switch project (fresh)" "C-x p" "project")) (provide 'init-project) ;;; init-project.el ends here