summaryrefslogtreecommitdiff
path: root/lisp/progmodes/project.el
diff options
context:
space:
mode:
Diffstat (limited to 'lisp/progmodes/project.el')
-rw-r--r--lisp/progmodes/project.el604
1 files changed, 474 insertions, 130 deletions
diff --git a/lisp/progmodes/project.el b/lisp/progmodes/project.el
index b671a08b744..a10e24f3e28 100644
--- a/lisp/progmodes/project.el
+++ b/lisp/progmodes/project.el
@@ -1,7 +1,7 @@
;;; project.el --- Operations on the current project -*- lexical-binding: t; -*-
;; Copyright (C) 2015-2024 Free Software Foundation, Inc.
-;; Version: 0.9.8
+;; Version: 0.10.0
;; Package-Requires: ((emacs "26.1") (xref "1.4.0"))
;; This is a GNU ELPA :core package. Avoid functionality that is not
@@ -197,14 +197,27 @@ CL struct.")
"Value to use instead of `default-directory' when detecting the project.
When it is non-nil, `project-current' will always skip prompting too.")
+(defcustom project-prompter #'project-prompt-project-dir
+ "Function to call to prompt for a project.
+Called with no arguments and should return a project root dir."
+ :type '(choice (const :tag "Prompt for a project directory"
+ project-prompt-project-dir)
+ (const :tag "Prompt for a project name"
+ project-prompt-project-name)
+ (function :tag "Custom function" nil))
+ :group 'project
+ :version "30.1")
+
;;;###autoload
(defun project-current (&optional maybe-prompt directory)
"Return the project instance in DIRECTORY, defaulting to `default-directory'.
When no project is found in that directory, the result depends on
the value of MAYBE-PROMPT: if it is nil or omitted, return nil,
-else ask the user for a directory in which to look for the
-project, and if no project is found there, return a \"transient\"
+else prompt the user for the project to use. To prompt for a
+project, call the function specified by `project-prompter', which
+returns the directory in which to look for the project. If no
+project is found in that directory, return a \"transient\"
project instance.
The \"transient\" project instance is a special kind of value
@@ -216,12 +229,13 @@ See the doc string of `project-find-functions' for the general form
of the project instance object."
(unless directory (setq directory (or project-current-directory-override
default-directory)))
- (let ((pr (project--find-in-directory directory)))
+ (let ((pr (project--find-in-directory directory))
+ (non-essential (not maybe-prompt)))
(cond
(pr)
((unless project-current-directory-override
maybe-prompt)
- (setq directory (project-prompt-project-dir)
+ (setq directory (funcall project-prompter)
pr (project--find-in-directory directory))))
(when maybe-prompt
(if pr
@@ -232,7 +246,12 @@ of the project instance object."
pr))
(defun project--find-in-directory (dir)
- (run-hook-with-args-until-success 'project-find-functions dir))
+ ;; Use 'ignore-error' when 27.1 is the minimum supported.
+ (condition-case nil
+ (run-hook-with-args-until-success 'project-find-functions dir)
+ ;; Maybe we'd like to continue to the next backend instead? Let's
+ ;; see if somebody ever ends up in that situation.
+ (permission-denied nil)))
(defvar project--within-roots-fallback nil)
@@ -397,7 +416,8 @@ the buffer's value of `default-directory'."
(defcustom project-vc-ignores nil
"List of patterns to add to `project-ignores'."
:type '(repeat string))
-;;;###autoload(put 'project-vc-ignores 'safe-local-variable #'listp)
+;; Change to `list-of-strings-p' when support for Emacs 28 is dropped.
+;;;###autoload(put 'project-vc-ignores 'safe-local-variable (lambda (val) (and (listp val) (not (memq nil (mapcar #'stringp val))))))
(defcustom project-vc-merge-submodules t
"Non-nil to consider submodules part of the parent project.
@@ -452,6 +472,7 @@ variables, such as `project-vc-ignores' or `project-vc-name'."
:type '(repeat string)
:version "29.1"
:package-version '(project . "0.9.0"))
+;; Change to `list-of-strings-p' when support for Emacs 28 is dropped.
;;;###autoload(put 'project-vc-extra-root-markers 'safe-local-variable (lambda (val) (and (listp val) (not (memq nil (mapcar #'stringp val))))))
;; FIXME: Using the current approach, major modes are supposed to set
@@ -552,6 +573,12 @@ See `project-vc-extra-root-markers' for the marker value format.")
(let* ((parent (file-name-directory (directory-file-name root))))
(setq root (vc-call-backend 'Git 'root parent))))
(when root
+ (when (not backend)
+ (let* ((project-vc-extra-root-markers nil)
+ ;; Avoid submodules scan.
+ (enable-dir-local-variables nil)
+ (parent (project-try-vc root)))
+ (and parent (setq backend (nth 1 parent)))))
(setq project (list 'vc backend root))
;; FIXME: Cache for a shorter time.
(vc-file-setprop dir 'project-vc project)
@@ -576,7 +603,7 @@ See `project-vc-extra-root-markers' for the marker value format.")
(goto-char (point-min))
;; Kind of a hack to distinguish a submodule from
;; other cases of .git files pointing elsewhere.
- (looking-at "gitdir: [./]+/\\.git/modules/"))
+ (looking-at "gitdir: .+/\\.git/\\(worktrees/.*\\)?modules/"))
t)
(t nil))))
@@ -626,6 +653,7 @@ See `project-vc-extra-root-markers' for the marker value format.")
(include-untracked (project--value-in-dir
'project-vc-include-untracked
dir))
+ (submodules (project--git-submodules))
files)
(setq args (append args
'("-c" "--exclude-standard")
@@ -657,23 +685,25 @@ See `project-vc-extra-root-markers' for the marker value format.")
i)))
extra-ignores)))))
(setq files
- (mapcar
- (lambda (file) (concat default-directory file))
- (split-string
- (apply #'vc-git--run-command-string nil "ls-files" args)
- "\0" t)))
+ (delq nil
+ (mapcar
+ (lambda (file)
+ (unless (member file submodules)
+ (concat default-directory file)))
+ (split-string
+ (apply #'vc-git--run-command-string nil "ls-files" args)
+ "\0" t))))
(when (project--vc-merge-submodules-p default-directory)
;; Unfortunately, 'ls-files --recurse-submodules' conflicts with '-o'.
- (let* ((submodules (project--git-submodules))
- (sub-files
- (mapcar
- (lambda (module)
- (when (file-directory-p module)
- (project--vc-list-files
- (concat default-directory module)
- backend
- extra-ignores)))
- submodules)))
+ (let ((sub-files
+ (mapcar
+ (lambda (module)
+ (when (file-directory-p module)
+ (project--vc-list-files
+ (concat default-directory module)
+ backend
+ extra-ignores)))
+ submodules)))
(setq files
(apply #'nconc files sub-files))))
;; 'git ls-files' returns duplicate entries for merge conflicts.
@@ -718,11 +748,10 @@ See `project-vc-extra-root-markers' for the marker value format.")
(cl-defmethod project-ignores ((project (head vc)) dir)
(let* ((root (nth 2 project))
- backend)
+ (backend (cadr project)))
(append
(when (and backend
(file-equal-p dir root))
- (setq backend (cadr project))
(delq
nil
(mapcar
@@ -780,8 +809,10 @@ DIRS must contain directory names."
(with-temp-buffer
(setq default-directory dir)
(let ((enable-local-variables :all))
- (hack-dir-local-variables-non-file-buffer))
- (symbol-value var)))
+ (hack-dir-local-variables))
+ ;; Don't use `hack-local-variables-apply' to avoid setting modes.
+ (alist-get var file-local-variables-alist
+ (symbol-value var))))
(cl-defmethod project-buffers ((project (head vc)))
(let* ((root (expand-file-name (file-name-as-directory (project-root project))))
@@ -827,6 +858,7 @@ DIRS must contain directory names."
(define-key map "G" 'project-or-external-find-regexp)
(define-key map "r" 'project-query-replace-regexp)
(define-key map "x" 'project-execute-extended-command)
+ (define-key map "o" 'project-any-command)
(define-key map "\C-b" 'project-list-buffers)
map)
"Keymap for project commands.")
@@ -860,6 +892,17 @@ DIRS must contain directory names."
(call-interactively cmd)
(user-error "%s is undefined" (key-description key)))))
+(defun project--other-place-prefix (place &optional extra-keymap)
+ (cl-assert (member place '(window frame tab)))
+ (prefix-command-preserve-state)
+ (let ((inhibit-message t)) (funcall (intern (format "other-%s-prefix" place))))
+ (message "Display next project command buffer in a new %s..." place)
+ ;; Should return exitfun from set-transient-map
+ (set-transient-map (if extra-keymap
+ (make-composed-keymap project-prefix-map
+ extra-keymap)
+ project-prefix-map)))
+
;;;###autoload
(defun project-other-window-command ()
"Run project command, displaying resultant buffer in another window.
@@ -869,9 +912,11 @@ The following commands are available:
\\{project-prefix-map}
\\{project-other-window-map}"
(interactive)
- (project--other-place-command '((display-buffer-pop-up-window)
- (inhibit-same-window . t))
- project-other-window-map))
+ (if (< emacs-major-version 30)
+ (project--other-place-command '((display-buffer-pop-up-window)
+ (inhibit-same-window . t))
+ project-other-window-map)
+ (project--other-place-prefix 'window project-other-window-map)))
;;;###autoload (define-key ctl-x-4-map "p" #'project-other-window-command)
@@ -884,8 +929,10 @@ The following commands are available:
\\{project-prefix-map}
\\{project-other-frame-map}"
(interactive)
- (project--other-place-command '((display-buffer-pop-up-frame))
- project-other-frame-map))
+ (if (< emacs-major-version 30)
+ (project--other-place-command '((display-buffer-pop-up-frame))
+ project-other-frame-map)
+ (project--other-place-prefix 'frame project-other-frame-map)))
;;;###autoload (define-key ctl-x-5-map "p" #'project-other-frame-command)
@@ -897,7 +944,9 @@ The following commands are available:
\\{project-prefix-map}"
(interactive)
- (project--other-place-command '((display-buffer-in-new-tab))))
+ (if (< emacs-major-version 30)
+ (project--other-place-command '((display-buffer-in-new-tab)))
+ (project--other-place-prefix 'tab)))
;;;###autoload
(when (bound-and-true-p tab-prefix-map)
@@ -946,9 +995,7 @@ requires quoting, e.g. `\\[quoted-insert]<space>'."
;;;###autoload
(defun project-or-external-find-regexp (regexp)
- "Find all matches for REGEXP in the project roots or external roots.
-With \\[universal-argument] prefix, you can specify the file name
-pattern to search for."
+ "Find all matches for REGEXP in the project roots or external roots."
(interactive (list (project--read-regexp)))
(require 'xref)
(let* ((pr (project-current t))
@@ -976,12 +1023,30 @@ pattern to search for."
(read-regexp "Find regexp" (and sym (regexp-quote sym))
project-regexp-history-variable)))
+(defun project--find-default-from (filename project)
+ "Ensure FILENAME is in PROJECT.
+
+Usually, just return FILENAME. But if
+`project-current-directory-override' is set, adjust it to be
+relative to PROJECT instead.
+
+This supports using a relative file name from the current buffer
+when switching projects with `project-switch-project' and then
+using a command like `project-find-file'."
+ (if-let (filename-proj (and project-current-directory-override
+ (project-current nil default-directory)))
+ ;; file-name-concat requires Emacs 28+
+ (concat (file-name-as-directory (project-root project))
+ (file-relative-name filename (project-root filename-proj)))
+ filename))
+
;;;###autoload
(defun project-find-file (&optional include-all)
"Visit a file (with completion) in the current project.
The filename at point (determined by `thing-at-point'), if any,
-is available as part of \"future history\".
+is available as part of \"future history\". If none, the current
+buffer's file name is used.
If INCLUDE-ALL is non-nil, or with prefix argument when called
interactively, include all files under the project root, except
@@ -992,7 +1057,7 @@ for VCS directories listed in `vc-directory-exclusion-list'."
(dirs (list root)))
(project-find-file-in
(or (thing-at-point 'filename)
- (and buffer-file-name (file-relative-name buffer-file-name root)))
+ (and buffer-file-name (project--find-default-from buffer-file-name pr)))
dirs pr include-all)))
;;;###autoload
@@ -1000,17 +1065,23 @@ for VCS directories listed in `vc-directory-exclusion-list'."
"Visit a file (with completion) in the current project or external roots.
The filename at point (determined by `thing-at-point'), if any,
-is available as part of \"future history\".
+is available as part of \"future history\". If none, the current
+buffer's file name is used.
If INCLUDE-ALL is non-nil, or with prefix argument when called
interactively, include all files under the project root, except
for VCS directories listed in `vc-directory-exclusion-list'."
(interactive "P")
+ (defvar project-file-history-behavior)
(let* ((pr (project-current t))
(dirs (cons
(project-root pr)
- (project-external-roots pr))))
- (project-find-file-in (thing-at-point 'filename) dirs pr include-all)))
+ (project-external-roots pr)))
+ (project-file-history-behavior t))
+ (project-find-file-in
+ (or (thing-at-point 'filename)
+ (and buffer-file-name (project--find-default-from buffer-file-name pr)))
+ dirs pr include-all)))
(defcustom project-read-file-name-function #'project--read-file-cpd-relative
"Function to call to read a file name from a list.
@@ -1023,6 +1094,27 @@ For the arguments list, see `project--read-file-cpd-relative'."
:group 'project
:version "27.1")
+(defcustom project-file-history-behavior t
+ "If `relativize', entries in `file-name-history' are adjusted.
+
+History entries shown in `project-find-file', `project-find-dir',
+(from `file-name-history') are adjusted to be relative to the
+current project root, instead of the project which added those
+paths. This only affects history entries added by earlier calls
+to `project-find-file' or `project-find-dir'.
+
+This has the effect of sharing more history between projects."
+ :type '(choice (const :tag "Default behavior" t)
+ (const :tag "Adjust to be relative to current" relativize))
+ :group 'project
+ :version "30.1")
+
+(defun project--transplant-file-name (filename project)
+ (when-let ((old-root (get-text-property 0 'project filename)))
+ (expand-file-name
+ (file-relative-name filename old-root)
+ (project-root project))))
+
(defun project--read-file-cpd-relative (prompt
all-files &optional predicate
hist mb-default)
@@ -1045,27 +1137,31 @@ by the user at will."
(setq all-files
(delete common-parent-directory all-files))
t))
+ (mb-default (if (and common-parent-directory
+ mb-default
+ (file-name-absolute-p mb-default))
+ (file-relative-name mb-default common-parent-directory)
+ mb-default))
(substrings (mapcar (lambda (s) (substring s cpd-length)) all-files))
(_ (when included-cpd
(setq substrings (cons "./" substrings))))
(new-collection (project--file-completion-table substrings))
- (abbr-cpd (abbreviate-file-name common-parent-directory))
- (abbr-cpd-length (length abbr-cpd))
- (relname (cl-letf ((history-add-new-input nil)
- ((symbol-value hist)
- (mapcan
- (lambda (s)
- (and (string-prefix-p abbr-cpd s)
- (not (eq abbr-cpd-length (length s)))
- (list (substring s abbr-cpd-length))))
- (symbol-value hist))))
+ (abs-cpd (expand-file-name common-parent-directory))
+ (abs-cpd-length (length abs-cpd))
+ (relname (cl-letf* ((non-essential t) ;Avoid new Tramp connections.
+ ((symbol-value hist)
+ (mapcan
+ (lambda (s)
+ (setq s (expand-file-name s))
+ (and (string-prefix-p abs-cpd s)
+ (not (eq abs-cpd-length (length s)))
+ (list (substring s abs-cpd-length))))
+ (symbol-value hist))))
(project--completing-read-strict prompt
new-collection
predicate
hist mb-default)))
(absname (expand-file-name relname common-parent-directory)))
- (when (and hist history-add-new-input)
- (add-to-history hist (abbreviate-file-name absname)))
absname))
(defun project--read-file-absolute (prompt
@@ -1076,10 +1172,33 @@ by the user at will."
predicate
hist mb-default))
+(defun project--read-file-name ( project prompt
+ all-files &optional predicate
+ hist mb-default)
+ "Call `project-read-file-name-function' with appropriate history.
+
+Depending on `project-file-history-behavior', entries are made
+project-relative where possible."
+ (let ((file
+ (cl-letf ((history-add-new-input nil)
+ ((symbol-value hist)
+ (if (eq project-file-history-behavior 'relativize)
+ (mapcar
+ (lambda (f)
+ (or (project--transplant-file-name f project) f))
+ (symbol-value hist))
+ (symbol-value hist))))
+ (funcall project-read-file-name-function
+ prompt all-files predicate hist mb-default))))
+ (when (and hist history-add-new-input)
+ (add-to-history hist
+ (propertize file 'project (project-root project))))
+ file))
+
(defun project-find-file-in (suggested-filename dirs project &optional include-all)
"Complete a file name in DIRS in PROJECT and visit the result.
-SUGGESTED-FILENAME is a relative file name, or part of it, which
+SUGGESTED-FILENAME is a file name, or part of it, which
is used as part of \"future history\".
If INCLUDE-ALL is non-nil, or with prefix argument when called
@@ -1096,9 +1215,10 @@ directories listed in `vc-directory-exclusion-list'."
dirs)
(project-files project dirs)))
(completion-ignore-case read-file-name-completion-ignore-case)
- (file (funcall project-read-file-name-function
- "Find file" all-files nil 'file-name-history
- suggested-filename)))
+ (file (project--read-file-name
+ project "Find file"
+ all-files nil 'file-name-history
+ suggested-filename)))
(if (string= file "")
(user-error "You didn't specify the file")
(find-file file))))
@@ -1119,7 +1239,10 @@ directories listed in `vc-directory-exclusion-list'."
;;;###autoload
(defun project-find-dir ()
- "Start Dired in a directory inside the current project."
+ "Start Dired in a directory inside the current project.
+
+The current buffer's `default-directory' is available as part of
+\"future history\"."
(interactive)
(let* ((project (project-current t))
(all-files (project-files project))
@@ -1130,11 +1253,13 @@ directories listed in `vc-directory-exclusion-list'."
;; https://stackoverflow.com/a/50685235/615245 for possible
;; implementation.
(all-dirs (mapcar #'file-name-directory all-files))
- (dir (funcall project-read-file-name-function
- "Dired"
- ;; Some completion UIs show duplicates.
- (delete-dups all-dirs)
- nil 'file-name-history)))
+ (dir (project--read-file-name
+ project "Dired"
+ ;; Some completion UIs show duplicates.
+ (delete-dups all-dirs)
+ nil 'file-name-history
+ (and default-directory
+ (project--find-default-from default-directory project)))))
(dired dir)))
;;;###autoload
@@ -1212,8 +1337,7 @@ command \\[fileloop-continue]."
(interactive "sSearch (regexp): ")
(fileloop-initialize-search
regexp
- ;; XXX: See the comment in project-query-replace-regexp.
- (cl-delete-if-not #'file-regular-p (project-files (project-current t)))
+ (project-files (project-current t))
'default)
(fileloop-continue))
@@ -1234,20 +1358,20 @@ If you exit the `query-replace', you can later continue the
(list from to))))
(fileloop-initialize-replace
from to
- ;; XXX: Filter out Git submodules, which are not regular files.
- ;; `project-files' can return those, which is arguably suboptimal,
- ;; but removing them eagerly has performance cost.
- (cl-delete-if-not #'file-regular-p (project-files (project-current t)))
+ (project-files (project-current t))
'default)
(fileloop-continue))
(defvar compilation-read-command)
(declare-function compilation-read-command "compile")
+(declare-function recompile "compile")
(defun project-prefixed-buffer-name (mode)
(concat "*"
- (file-name-nondirectory
- (directory-file-name default-directory))
+ (if-let ((proj (project-current nil)))
+ (project-name proj)
+ (file-name-nondirectory
+ (directory-file-name default-directory)))
"-"
(downcase mode)
"*"))
@@ -1259,7 +1383,7 @@ If non-nil, it overrides `compilation-buffer-name-function' for
:version "28.1"
:group 'project
:type '(choice (const :tag "Default" nil)
- (const :tag "Prefixed with root directory name"
+ (const :tag "Prefixed with project name"
project-prefixed-buffer-name)
(function :tag "Custom function")))
@@ -1274,6 +1398,18 @@ If non-nil, it overrides `compilation-buffer-name-function' for
compilation-buffer-name-function)))
(call-interactively #'compile)))
+(defun project-recompile (&optional edit-command)
+ "Run `recompile' with appropriate buffer."
+ (declare (interactive-only recompile))
+ (interactive "P")
+ (let ((compilation-buffer-name-function
+ (or project-compilation-buffer-name-function
+ ;; Should we error instead? When there's no
+ ;; project-specific naming, there is no point in using
+ ;; this command.
+ compilation-buffer-name-function)))
+ (recompile edit-command)))
+
(defcustom project-ignore-buffer-conditions nil
"List of conditions to filter the buffers to be switched to.
If any of these conditions are satisfied for a buffer in the
@@ -1309,13 +1445,23 @@ general form of conditions."
(and (memq (cdr buffer) buffers)
(not
(project--buffer-check
- (cdr buffer) project-ignore-buffer-conditions))))))
- (read-buffer
- "Switch to buffer: "
- (when (funcall predicate (cons other-name other-buffer))
- other-name)
- nil
- predicate)))
+ (cdr buffer) project-ignore-buffer-conditions)))))
+ (buffer (read-buffer
+ "Switch to buffer: "
+ (when (funcall predicate (cons other-name other-buffer))
+ other-name)
+ nil
+ predicate)))
+ ;; XXX: This check hardcodes the default buffer-belonging relation
+ ;; which `project-buffers' is allowed to override. Straighten
+ ;; this up sometime later. Or not. Since we can add a method
+ ;; `project-contains-buffer-p', but a separate method to create a
+ ;; new project buffer seems too much.
+ (if (or (get-buffer buffer)
+ (file-in-directory-p default-directory (project-root pr)))
+ buffer
+ (let ((default-directory (project-root pr)))
+ (get-buffer-create buffer)))))
;;;###autoload
(defun project-switch-to-buffer (buffer-or-name)
@@ -1370,7 +1516,8 @@ ARG, show only buffers that are visiting files."
(lambda (buffer)
(let ((name (buffer-name buffer))
(file (buffer-file-name buffer)))
- (and (or (not (string= (substring name 0 1) " "))
+ (and (or Buffer-menu-show-internal
+ (not (string= (substring name 0 1) " "))
file)
(not (eq buffer (current-buffer)))
(or file (not Buffer-menu-files-only)))))
@@ -1380,6 +1527,7 @@ ARG, show only buffers that are visiting files."
(let ((buf (list-buffers-noselect
arg (with-current-buffer
(get-buffer-create "*Buffer List*")
+ (setq-local Buffer-menu-show-internal nil)
(let ((Buffer-menu-files-only arg))
(funcall buffer-list-function))))))
(with-current-buffer buf
@@ -1451,6 +1599,7 @@ Used by `project-kill-buffers'."
:package-version '(project . "0.8.2"))
;;;###autoload(put 'project-kill-buffers-display-buffer-list 'safe-local-variable #'booleanp)
+;; FIXME: Could this be replaced by `buffer-match-p' in Emacs 29+?
(defun project--buffer-check (buf conditions)
"Check if buffer BUF matches any element of the list CONDITIONS.
See `project-kill-buffer-conditions' or
@@ -1510,7 +1659,7 @@ Also see the `project-kill-buffers-display-buffer-list' variable."
(yes-or-no-p
(format "Kill %d buffers in %s? "
(length bufs)
- (project-root pr))))))
+ (project-name pr))))))
(cond (no-confirm
(mapc #'kill-buffer bufs))
((null bufs)
@@ -1556,7 +1705,15 @@ With some possible metadata (to be decided).")
(when (file-exists-p filename)
(with-temp-buffer
(insert-file-contents filename)
- (read (current-buffer)))))
+ (mapcar
+ (lambda (elem)
+ (let ((name (car elem)))
+ (list (if (file-remote-p name) name
+ (abbreviate-file-name name)))))
+ (condition-case nil
+ (read (current-buffer))
+ (end-of-file
+ (warn "Failed to read the projects list file due to unexpected EOF")))))))
(unless (seq-every-p
(lambda (elt) (stringp (car-safe elt)))
project--list)
@@ -1576,16 +1733,20 @@ With some possible metadata (to be decided).")
(insert ";;; -*- lisp-data -*-\n")
(let ((print-length nil)
(print-level nil))
- (pp project--list (current-buffer)))
+ (pp (mapcar (lambda (elem)
+ (let ((name (car elem)))
+ (list (if (file-remote-p name) name
+ (expand-file-name name)))))
+ project--list)
+ (current-buffer)))
(write-region nil nil filename nil 'silent))))
-;;;###autoload
-(defun project-remember-project (pr &optional no-write)
- "Add project PR to the front of the project list.
+(defun project--remember-dir (root &optional no-write)
+ "Add project root ROOT to the front of the project list.
Save the result in `project-list-file' if the list of projects
has changed, and NO-WRITE is nil."
(project--ensure-read-project-list)
- (let ((dir (project-root pr)))
+ (let ((dir (abbreviate-file-name root)))
(unless (equal (caar project--list) dir)
(dolist (ent project--list)
(when (equal dir (car ent))
@@ -1594,6 +1755,13 @@ has changed, and NO-WRITE is nil."
(unless no-write
(project--write-project-list)))))
+;;;###autoload
+(defun project-remember-project (pr &optional no-write)
+ "Add project PR to the front of the project list.
+Save the result in `project-list-file' if the list of projects
+has changed, and NO-WRITE is nil."
+ (project--remember-dir (project-root pr) no-write))
+
(defun project--remove-from-project-list (project-root report-message)
"Remove directory PROJECT-ROOT of a missing project from the project list.
If the directory was in the list before the removal, save the
@@ -1601,7 +1769,7 @@ result in `project-list-file'. Announce the project's removal
from the list using REPORT-MESSAGE, which is a format string
passed to `message' as its first argument."
(project--ensure-read-project-list)
- (when-let ((ent (assoc project-root project--list)))
+ (when-let ((ent (assoc (abbreviate-file-name project-root) project--list)))
(setq project--list (delq ent project--list))
(message report-message project-root)
(project--write-project-list)))
@@ -1611,10 +1779,12 @@ passed to `message' as its first argument."
"Remove directory PROJECT-ROOT from the project list.
PROJECT-ROOT is the root directory of a known project listed in
the project list."
- (interactive (list (project-prompt-project-dir)))
+ (interactive (list (funcall project-prompter)))
(project--remove-from-project-list
project-root "Project `%s' removed from known projects"))
+(defvar project--dir-history)
+
(defun project-prompt-project-dir ()
"Prompt the user for a directory that is one of the known project roots.
The project is chosen among projects known from the project list,
@@ -1627,14 +1797,53 @@ It's also possible to enter an arbitrary directory not in the list."
;; completion style).
(project--file-completion-table
(append project--list `(,dir-choice))))
+ (project--dir-history (project-known-project-roots))
(pr-dir ""))
(while (equal pr-dir "")
;; If the user simply pressed RET, do this again until they don't.
- (setq pr-dir (completing-read "Select project: " choices nil t)))
+ (setq pr-dir
+ (let (history-add-new-input)
+ (completing-read "Select project: " choices nil t nil 'project--dir-history))))
(if (equal pr-dir dir-choice)
(read-directory-name "Select directory: " default-directory nil t)
pr-dir)))
+(defvar project--name-history)
+
+(defun project-prompt-project-name ()
+ "Prompt the user for a project, by name, that is one of the known project roots.
+The project is chosen among projects known from the project list,
+see `project-list-file'.
+It's also possible to enter an arbitrary directory not in the list."
+ (let* ((dir-choice "... (choose a dir)")
+ project--name-history
+ (choices
+ (let (ret)
+ ;; Iterate in reverse order so project--name-history is in
+ ;; the same order as project--list.
+ (dolist (dir (reverse (project-known-project-roots)))
+ ;; We filter out directories that no longer map to a project,
+ ;; since they don't have a clean project-name.
+ (when-let ((proj (project--find-in-directory dir))
+ (name (project-name proj)))
+ (push name project--name-history)
+ (push (cons name proj) ret)))
+ (reverse ret)))
+ ;; XXX: Just using this for the category (for the substring
+ ;; completion style).
+ (table (project--file-completion-table
+ (reverse (cons dir-choice choices))))
+ (pr-name ""))
+ (while (equal pr-name "")
+ ;; If the user simply pressed RET, do this again until they don't.
+ (setq pr-name
+ (let (history-add-new-input)
+ (completing-read "Select project: " table nil t nil 'project--name-history))))
+ (if (equal pr-name dir-choice)
+ (read-directory-name "Select directory: " default-directory nil t)
+ (let ((proj (assoc pr-name choices)))
+ (if (stringp proj) proj (project-root (cdr proj)))))))
+
;;;###autoload
(defun project-known-project-roots ()
"Return the list of root directories of all known projects."
@@ -1649,6 +1858,44 @@ It's also possible to enter an arbitrary directory not in the list."
(let ((default-directory (project-root (project-current t))))
(call-interactively #'execute-extended-command)))
+;;;###autoload
+(defun project-any-command (&optional overriding-map prompt-format)
+ "Run the next command in the current project.
+
+If the command name starts with `project-', or its symbol has
+property `project-aware', it gets passed the project to use
+with the variable `project-current-directory-override'.
+Otherwise, `default-directory' is temporarily set to the current
+project's root.
+
+If OVERRIDING-MAP is non-nil, it will be used as
+`overriding-terminal-local-map' to provide shorter bindings
+from that map which will take priority over the global ones."
+ (interactive)
+ (let* ((pr (project-current t))
+ (prompt-format (or prompt-format "[execute in %s]:"))
+ (command (let ((overriding-terminal-local-map overriding-map))
+ (key-binding (read-key-sequence
+ (format prompt-format (project-root pr)))
+ t)))
+ (root (project-root pr)))
+ (when command
+ (if (when (symbolp command)
+ (or (string-prefix-p "project-" (symbol-name command))
+ (get command 'project-aware)))
+ (let ((project-current-directory-override root))
+ (call-interactively command))
+ (let ((default-directory root))
+ (call-interactively command))))))
+
+;;;###autoload
+(defun project-prefix-or-any-command ()
+ "Run the next command in the current project.
+Works like `project-any-command', but also mixes in the shorter
+bindings from `project-prefix-map'."
+ (interactive)
+ (project-any-command project-prefix-map "[execute in %s]:"))
+
(defun project-remember-projects-under (dir &optional recursive)
"Index all projects below a directory DIR.
If RECURSIVE is non-nil, recurse into all subdirectories to find
@@ -1657,35 +1904,28 @@ the progress. The function returns the number of detected
projects."
(interactive "DDirectory: \nP")
(project--ensure-read-project-list)
- (let ((queue (list dir))
- (count 0)
- (known (make-hash-table
- :size (* 2 (length project--list))
- :test #'equal )))
+ (let ((dirs (if recursive
+ (directory-files-recursively dir "" t)
+ (directory-files dir t)))
+ (known (make-hash-table :size (* 2 (length project--list))
+ :test #'equal))
+ (count 0))
(dolist (project (mapcar #'car project--list))
(puthash project t known))
- (while queue
- (when-let ((subdir (pop queue))
- ((file-directory-p subdir)))
- (when-let ((project (project--find-in-directory subdir))
- (project-root (project-root project))
- ((not (gethash project-root known))))
- (project-remember-project project t)
- (puthash project-root t known)
- (message "Found %s..." project-root)
- (setq count (1+ count)))
- (when (and recursive (file-directory-p subdir))
- (setq queue
- (nconc
- (directory-files
- subdir t directory-files-no-dot-files-regexp t)
- queue)))))
- (unless (eq recursive 'in-progress)
- (if (zerop count)
- (message "No projects were found")
- (project--write-project-list)
- (message "%d project%s were found"
- count (if (= count 1) "" "s"))))
+ (dolist (subdir dirs)
+ (when-let (((file-directory-p subdir))
+ (project (project--find-in-directory subdir))
+ (project-root (project-root project))
+ ((not (gethash project-root known))))
+ (project-remember-project project t)
+ (puthash project-root t known)
+ (message "Found %s..." project-root)
+ (setq count (1+ count))))
+ (if (zerop count)
+ (message "No projects were found")
+ (project--write-project-list)
+ (message "%d project%s were found"
+ count (if (= count 1) "" "s")))
count))
(defun project-forget-zombie-projects ()
@@ -1727,7 +1967,8 @@ forgotten projects."
(project-find-regexp "Find regexp")
(project-find-dir "Find directory")
(project-vc-dir "VC-Dir")
- (project-eshell "Eshell"))
+ (project-eshell "Eshell")
+ (project-any-command "Other"))
"Alist mapping commands to descriptions.
Used by `project-switch-project' to construct a dispatch menu of
commands available upon \"switching\" to another project.
@@ -1751,7 +1992,9 @@ invoked immediately without any dispatch menu."
(choice :tag "Key to press"
(const :tag "Infer from the keymap" nil)
(character :tag "Explicit key"))))
- (symbol :tag "Single command")))
+ (const :tag "Use both short keys and global bindings"
+ project-prefix-or-any-command)
+ (symbol :tag "Custom command")))
(defcustom project-switch-use-entire-map nil
"Whether `project-switch-project' will use the entire `project-prefix-map'.
@@ -1764,7 +2007,28 @@ listed in the dispatch menu produced from `project-switch-commands'."
:group 'project
:version "28.1")
+(defcustom project-key-prompt-style (if (facep 'help-key-binding)
+ t
+ 'brackets)
+ "Which presentation to use when asking to choose a command by key.
+
+When `brackets', use text brackets and `bold' for the character.
+Otherwise, use the face `help-key-binding' in the prompt."
+ :type '(choice (const :tag "Using help-key-binding face" t)
+ (const :tag "Using bold face and brackets" brackets))
+ :group 'project
+ :version "30.1")
+
(defun project--keymap-prompt ()
+ "Return a prompt for the project switching using the prefix map."
+ (let (keys)
+ (map-keymap
+ (lambda (evt _)
+ (when (characterp evt) (push evt keys)))
+ project-prefix-map)
+ (mapconcat (lambda (key) (help-key-description (string key) nil)) keys " ")))
+
+(defun project--menu-prompt ()
"Return a prompt for the project switching dispatch menu."
(mapconcat
(pcase-lambda (`(,cmd ,label ,key))
@@ -1776,9 +2040,13 @@ listed in the dispatch menu produced from `project-switch-commands'."
(let ((key (if key
(vector key)
(where-is-internal cmd (list project-prefix-map) t))))
- (format "[%s] %s"
- (propertize (key-description key) 'face 'bold)
- label)))
+ (if (not (eq project-key-prompt-style 'brackets))
+ (format "%s %s"
+ (propertize (key-description key) 'face 'help-key-binding)
+ label)
+ (format "[%s] %s"
+ (propertize (key-description key) 'face 'bold)
+ label))))
project-switch-commands
" "))
@@ -1799,20 +2067,30 @@ listed in the dispatch menu produced from `project-switch-commands'."
(when-let ((cmd (nth 0 row))
(keychar (nth 2 row)))
(define-key temp-map (vector keychar) cmd)))))
- command)
+ command
+ choice)
(while (not command)
(let* ((overriding-local-map commands-map)
- (choice (read-key-sequence (project--keymap-prompt))))
+ (prompt (if project-switch-use-entire-map
+ (project--keymap-prompt)
+ (project--menu-prompt))))
+ (when choice
+ (setq prompt (concat prompt
+ (format " %s: %s"
+ (propertize "Unrecognized input"
+ 'face 'warning)
+ (help-key-description choice nil)))))
+ (setq choice (read-key-sequence (concat "Choose: " prompt)))
(when (setq command (lookup-key commands-map choice))
+ (when (numberp command) (setq command nil))
(unless (or project-switch-use-entire-map
(assq command commands-menu))
- ;; TODO: Add some hint to the prompt, like "key not
- ;; recognized" or something.
(setq command nil)))
(let ((global-command (lookup-key (current-global-map) choice)))
(when (memq global-command
'(keyboard-quit keyboard-escape-quit))
(call-interactively global-command)))))
+ (message nil)
command))
;;;###autoload
@@ -1823,12 +2101,78 @@ made from `project-switch-commands'.
When called in a program, it will use the project corresponding
to directory DIR."
- (interactive (list (project-prompt-project-dir)))
+ (interactive (list (funcall project-prompter)))
+ (project--remember-dir dir)
(let ((command (if (symbolp project-switch-commands)
project-switch-commands
- (project--switch-project-command))))
- (let ((project-current-directory-override dir))
- (call-interactively command))))
+ (project--switch-project-command)))
+ (buffer (current-buffer)))
+ (unwind-protect
+ (progn
+ (setq-local project-current-directory-override dir)
+ (call-interactively command))
+ (with-current-buffer buffer
+ (kill-local-variable 'project-current-directory-override)))))
+
+;;;###autoload
+(defun project-uniquify-dirname-transform (dirname)
+ "Uniquify name of directory DIRNAME using `project-name', if in a project.
+
+If you set `uniquify-dirname-transform' to this function,
+slash-separated components from `project-name' will be appended to
+the buffer's directory name when buffers from two different projects
+would otherwise have the same name."
+ (if-let (proj (project-current nil dirname))
+ (let ((root (project-root proj)))
+ (expand-file-name
+ (file-name-concat
+ (file-name-directory root)
+ (project-name proj)
+ (file-relative-name dirname root))))
+ dirname))
+
+;;; Project mode-line
+
+;;;###autoload
+(defcustom project-mode-line nil
+ "Whether to show current project name and Project menu on the mode line.
+This feature requires the presence of the following item in
+`mode-line-format': `(project-mode-line project-mode-line-format)'; it
+is part of the default mode line beginning with Emacs 30."
+ :type 'boolean
+ :group 'project
+ :version "30.1")
+
+(defvar project-mode-line-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line down-mouse-1]
+ (bound-and-true-p menu-bar-project-item))
+ map))
+
+(defvar project-mode-line-face nil
+ "Face name to use for the project name on the mode line.")
+
+(defvar project-mode-line-format '(:eval (project-mode-line-format)))
+(put 'project-mode-line-format 'risky-local-variable t)
+
+(defun project-mode-line-format ()
+ "Compose the project mode-line."
+ (when-let ((project (project-current)))
+ ;; Preserve the global value of 'last-coding-system-used'
+ ;; that 'write-region' needs to set for 'basic-save-buffer',
+ ;; but updating the mode line might occur at the same time
+ ;; during saving the buffer and 'project-name' can change
+ ;; 'last-coding-system-used' when reading the project name
+ ;; from .dir-locals.el also enables flyspell-mode (bug#66825).
+ (let ((last-coding-system-used last-coding-system-used))
+ (concat
+ " "
+ (propertize
+ (project-name project)
+ 'face project-mode-line-face
+ 'mouse-face 'mode-line-highlight
+ 'help-echo "mouse-1: Project menu"
+ 'local-map project-mode-line-map)))))
(provide 'project)
;;; project.el ends here