aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term.el21
-rw-r--r--modules/calibredb-epub-config.el133
-rw-r--r--modules/custom-buffer-file.el10
-rw-r--r--modules/dashboard-config.el9
-rw-r--r--modules/dirvish-config.el20
-rw-r--r--modules/duet-config.el19
-rw-r--r--modules/dwim-shell-config.el23
-rw-r--r--modules/help-config.el49
-rw-r--r--modules/linear-config.el58
-rw-r--r--modules/mail-config.el34
-rw-r--r--modules/markdown-config.el10
-rw-r--r--modules/music-config.el5
-rw-r--r--modules/org-capture-config.el224
-rw-r--r--modules/org-drill-config.el9
-rw-r--r--modules/org-roam-config.el10
-rw-r--r--modules/pearl-config.el66
-rw-r--r--modules/prog-general.el16
-rw-r--r--modules/reconcile-open-repos.el5
-rw-r--r--modules/selection-framework.el10
-rw-r--r--modules/signal-config.el61
-rw-r--r--modules/system-commands.el22
-rw-r--r--modules/system-defaults.el5
-rw-r--r--modules/system-lib.el11
-rw-r--r--modules/term-config.el31
-rw-r--r--modules/ui-navigation.el12
-rw-r--r--modules/user-constants.el8
26 files changed, 728 insertions, 153 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 1384f812..baf752fe 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -54,8 +54,10 @@
;; instead of toggling the current one.
;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its terminal
-;; buffer and window. Confirms first. Targets the current
-;; agent, the sole live agent, or prompts among several.
+;; buffer. Its window stays in the layout (swapped to the
+;; working buffer), so closing never collapses a split. Confirms
+;; first. Targets the current agent, the sole live agent, or
+;; prompts among several.
;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding.
;; (M-F9 is the primary; C-S-F9 may be swallowed by the
;; Wayland/PGTK layer on some machines.)
@@ -859,12 +861,14 @@ down."
(error nil)))
(defun cj/--ai-term-close-buffer (buffer)
- "Gracefully tear down AI-term BUFFER: tmux session, window, buffer.
+ "Gracefully tear down AI-term BUFFER: tmux session, then buffer.
Derives the tmux session name from BUFFER's `default-directory' (the
project dir the terminal was created in) and kills it so the agent
-process stops. Deletes BUFFER's window when it's shown and isn't the
-only window in its frame, then kills BUFFER (suppressing the
+process stops. When BUFFER is shown, swaps its window to a non-agent
+buffer (the working file) rather than deleting the window -- closing an
+agent must not collapse the user's window layout; the F9 hide toggle is
+what collapses the split. Then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-term buffer."
(when (cj/--ai-term-buffer-p buffer)
@@ -872,8 +876,11 @@ when BUFFER isn't an AI-term buffer."
(cj/--ai-term-tmux-session-name
(buffer-local-value 'default-directory buffer)))
(let ((win (get-buffer-window buffer)))
- (when (and win (> (length (window-list (window-frame win) 'never)) 1))
- (delete-window win)))
+ (when (window-live-p win)
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer buffer t))))))
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index 4243e509..a17bf8c9 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -51,6 +51,7 @@
(require 'user-constants) ;; for books-dir
(require 'subr-x)
+(require 'transient) ;; cj/calibredb-menu is a transient prefix
;; Declare functions from lazy-loaded packages
(declare-function calibredb-find-create-search-buffer "calibredb" ())
@@ -59,6 +60,24 @@
(declare-function nov-render-document "nov" ())
(defvar nov-text-width) ; from nov.el; set buffer-local here
+;; calibredb commands the curated menu drives (all autoloaded by calibredb)
+(declare-function calibredb-switch-library "calibredb" ())
+(declare-function calibredb-filter-by-book-format "calibredb" ())
+(declare-function calibredb-filter-by-author-sort "calibredb" ())
+(declare-function calibredb-search-clear-filter "calibredb" ())
+(declare-function calibredb-sort-by-author "calibredb" ())
+(declare-function calibredb-sort-by-title "calibredb" ())
+(declare-function calibredb-sort-by-pubdate "calibredb" ())
+(declare-function calibredb-sort-by-format "calibredb" ())
+(declare-function calibredb-find-file "calibredb" ())
+(declare-function calibredb-dispatch "calibredb" ())
+(declare-function calibredb-show-entry "calibredb" (entry &optional switch))
+(declare-function calibredb-find-candidate-at-point "calibredb" ())
+(declare-function calibredb-search-refresh-or-resume "calibredb" (&optional begin position))
+(defvar calibredb-show-entry-switch) ; from calibredb-show.el
+(defvar calibredb-sort-by) ; from calibredb-core.el
+(defvar calibredb-search-filter) ; from calibredb-search.el
+
;; -------------------------- CalibreDB Ebook Manager --------------------------
(defun cj/calibredb-clear-filters ()
@@ -73,6 +92,23 @@
;; empty string resets keyword filter and refreshes listing
(calibredb-search-keyword-filter ""))
+(defun cj/calibredb-describe-at-point ()
+ "Show the book at point in the docked *calibredb-entry* buffer.
+Displays the entry without switching focus back to the list, so it lands
+in the bottom-docked window (see the `display-buffer-alist' entry below)
+and q (`calibredb-entry-quit') dismisses it."
+ (interactive)
+ (calibredb-show-entry (car (calibredb-find-candidate-at-point))))
+
+(defun cj/--calibredb-sort-preserving-filter (field)
+ "Set `calibredb-sort-by' to FIELD and refresh, keeping the active filter.
+calibredb's own `calibredb-sort-by-*' commands refresh with
+`calibredb-search-refresh-and-clear-filter', which drops the active filter
+on every sort. This refreshes with `calibredb-search-refresh-or-resume',
+which re-applies `calibredb-search-filter' instead."
+ (setq calibredb-sort-by field)
+ (calibredb-search-refresh-or-resume))
+
(use-package calibredb
:commands calibredb
:bind
@@ -80,7 +116,10 @@
;; use built-in filter by tag, add clear-filters
(:map calibredb-search-mode-map
("l" . calibredb-filter-by-tag)
- ("L" . cj/calibredb-clear-filters))
+ ("L" . cj/calibredb-clear-filters)
+ ;; "?" -> curated menu of frequent workflows; "H" -> the full dispatch
+ ("?" . cj/calibredb-menu)
+ ("H" . calibredb-dispatch))
:config
;; basic config
(setq calibredb-root-dir books-dir)
@@ -88,6 +127,50 @@
(setq calibredb-program "/usr/bin/calibredb")
(setq calibredb-preferred-format "epub")
(setq calibredb-search-page-max-rows 500)
+ ;; Dock the book-detail buffer to the bottom 30%; q dismisses it.
+ ;; `pop-to-buffer' honours `display-buffer-alist' (the default
+ ;; `switch-to-buffer-other-window' would not).
+ (setq calibredb-show-entry-switch #'pop-to-buffer)
+ (add-to-list 'display-buffer-alist
+ '("\\`\\*calibredb-entry\\*\\'"
+ (display-buffer-at-bottom)
+ (window-height . 0.3)))
+ ;; A curated menu of the frequent calibredb workflows, bound to `?' in the
+ ;; search buffer; calibredb's own full dispatch (the wall of every command)
+ ;; moves to `H'. Defined here in `:config' so it only builds once calibredb
+ ;; (and its matching transient) is loaded. This is the "? brings up a
+ ;; discoverable help menu" convention.
+ (transient-define-prefix cj/calibredb-menu ()
+ "Frequent calibredb workflows."
+ [["Library"
+ ("l" "switch library" calibredb-switch-library)]
+ ["Filter"
+ ("f" "format" calibredb-filter-by-book-format)
+ ("a" "author" calibredb-filter-by-author-sort)
+ ("x" "reset filter" calibredb-search-clear-filter)]
+ ["Sort"
+ ("A" "author (last name)" calibredb-sort-by-author)
+ ("t" "title" calibredb-sort-by-title)
+ ("p" "pubdate" calibredb-sort-by-pubdate)
+ ("g" "group by format" calibredb-sort-by-format)]
+ ["Book"
+ ("o" "open" calibredb-find-file)
+ ("d" "describe" cj/calibredb-describe-at-point)
+ ("H" "full calibredb menu" calibredb-dispatch)]]
+ [("q" "quit" transient-quit-one)])
+
+ ;; Keep the active filter when sorting. calibredb's macro-generated
+ ;; `calibredb-sort-by-*' commands refresh-and-clear-filter, dropping the
+ ;; filter on every sort; override each to refresh-or-resume so the filter
+ ;; survives. Named advice keeps the override idempotent across reloads.
+ (dolist (field '(id title author format date pubdate tag size language))
+ (let ((cmd (intern (format "calibredb-sort-by-%s" field)))
+ (adv (intern (format "cj/--calibredb-sort-keep-filter-%s" field)))
+ (f field))
+ (defalias adv
+ (lambda (&rest _) (interactive) (cj/--calibredb-sort-preserving-filter f))
+ (format "Sort by %s, keeping the active filter (override)." field))
+ (advice-add cmd :override adv)))
;; search window display
(setq calibredb-size-show nil)
@@ -327,6 +410,54 @@ Try to use the Calibre book id from the parent folder name (for example,
("t" . nov-goto-toc)
("C-c C-b" . cj/nov-jump-to-calibredb)))
+;; ------------------------- Nov bookmark naming -------------------------------
+;; In a nov buffer "m" is bound to `bookmark-set' (above). nov's
+;; `nov-bookmark-make-record' names the record after `(buffer-name)' -- the EPUB
+;; filename, extension and all. Rebuild it as "Author, Title" parsed from the
+;; filename: under Calibre's "<Title> - <Author>.epub" naming the filename is
+;; more complete than the EPUB's embedded metadata (which carries truncated
+;; titles and author-sort "Last, First" forms).
+
+(defun cj/--nov-clean-title (s)
+ "Clean a title or author S parsed from an EPUB filename, or nil when blank.
+Restores a colon where Calibre sanitized \":\" to \"_\" (\"Frege_ A Guide\"
+-> \"Frege: A Guide\"), turns any leftover underscore into a space, and
+collapses runs of whitespace."
+ (when (stringp s)
+ (let* ((colon (replace-regexp-in-string "_ " ": " s))
+ (spaced (replace-regexp-in-string "_" " " colon))
+ (out (string-trim (replace-regexp-in-string "[ \t]+" " " spaced))))
+ (and (not (string-empty-p out)) out))))
+
+(defun cj/--nov-bookmark-name-from-file (path)
+ "Return \"Author, Title\" derived from an EPUB PATH's filename, or nil.
+Splits the filename (sans extension) on its last \" - \" into title and
+author per Calibre's \"<Title> - <Author>\" convention, restoring colons and
+reordering to \"Author, Title\". Falls back to the cleaned whole name when
+there is no \" - \" separator."
+ (when (and (stringp path) (not (string-empty-p path)))
+ (let ((base (file-name-sans-extension (file-name-nondirectory path))))
+ (if (string-match "\\`\\(.+\\) - \\(.+\\)\\'" base)
+ (let ((title (cj/--nov-clean-title (match-string 1 base)))
+ (author (cj/--nov-clean-title (match-string 2 base))))
+ (cond ((and author title) (format "%s, %s" author title))
+ (title title)
+ (author author)
+ (t nil)))
+ (cj/--nov-clean-title base)))))
+
+(defun cj/--nov-bookmark-rename-record (record)
+ "Replace RECORD's bookmark name with \"Author, Title\" from its EPUB filename.
+Advice (:filter-return) on `nov-bookmark-make-record'. RECORD is
+\(NAME . ALIST) carrying a `filename'; left unchanged when no name derives."
+ (let ((name (cj/--nov-bookmark-name-from-file
+ (alist-get 'filename (cdr record)))))
+ (if name (cons name (cdr record)) record)))
+
+(with-eval-after-load 'nov
+ (advice-add 'nov-bookmark-make-record :filter-return
+ #'cj/--nov-bookmark-rename-record))
+
(defun cj/--nov-image-padding-cols (col-width img-px font-width-px)
"Return left-padding columns to center an IMG-PX-wide image in COL-WIDTH cols.
FONT-WIDTH-PX is the column width in pixels; clamped up to 1 so a zero or
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
index 6c3e6c6e..84faf01d 100644
--- a/modules/custom-buffer-file.el
+++ b/modules/custom-buffer-file.el
@@ -48,6 +48,7 @@
;; mm-decode for email viewing (mm-handle-type is a macro, needs early require)
(require 'mm-decode)
(require 'external-open) ;; for cj/xdg-open, cj/open-this-file-with
+(require 'system-lib) ;; cj/confirm-strong (overwrite confirms), used below
;; cj/kill-buffer-and-window and cj/kill-other-window-buffer defined in undead-buffers.el
(declare-function cj/kill-buffer-and-window "undead-buffers")
@@ -156,7 +157,7 @@ When called interactively, prompts for confirmation if target file exists."
(condition-case _
(cj/--move-buffer-and-file dir nil)
(file-already-exists
- (if (yes-or-no-p (format "File %s exists; overwrite? " target))
+ (if (cj/confirm-strong (format "File %s exists; overwrite? " target))
(cj/--move-buffer-and-file dir t)
(message "File not moved"))))))
@@ -196,7 +197,7 @@ When called interactively, prompts for confirmation if target file exists."
(condition-case err
(cj/--rename-buffer-and-file new-name nil)
(file-already-exists
- (if (yes-or-no-p (format "File %s exists; overwrite? " new-name))
+ (if (cj/confirm-strong (format "File %s exists; overwrite? " new-name))
(cj/--rename-buffer-and-file new-name t)
(message "File not renamed")))
(error
@@ -338,7 +339,6 @@ Do not save the deleted text in the kill ring."
(kill-new (buffer-name))
(message "Copied: %s" (buffer-name)))
-(require 'system-lib)
(declare-function ansi-color-apply-on-region "ansi-color")
(defun cj/--diff-with-difftastic (file1 file2 buffer)
@@ -512,8 +512,8 @@ Signals an error if:
"m" #'cj/move-buffer-and-file
"r" #'cj/rename-buffer-and-file
"p" #'cj/copy-buffer-source-as-kill
- "d" #'cj/delete-buffer-and-file
- "D" #'cj/diff-buffer-with-file
+ "d" #'cj/diff-buffer-with-file
+ "D" #'cj/delete-buffer-and-file
"c" cj/copy-buffer-content-map
"n" #'cj/copy-buffer-name
"l" #'cj/copy-link-to-buffer-file
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index b4e4545d..3b8a3c5c 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -145,6 +145,13 @@ window."
;; --------------------------------- Dashboard ---------------------------------
;; a useful startup screen for Emacs
+(defun cj/--dashboard-exclude-emms-from-recentf ()
+ "Exclude the EMMS history file from recentf.
+Adds to `recentf-exclude' so entries set elsewhere (e.g. in
+system-defaults) are preserved rather than overwritten."
+ (require 'recentf)
+ (add-to-list 'recentf-exclude "/emms/history"))
+
(use-package dashboard
:demand t
:hook (emacs-startup . cj/dashboard-only)
@@ -196,7 +203,7 @@ window."
(setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks
(setq dashboard-recentf-show-base t) ;; show filename, not full path
(setq dashboard-recentf-item-format "%s")
- (setq recentf-exclude '("/emms/history")) ;; exclude EMMS history from recent files
+ (cj/--dashboard-exclude-emms-from-recentf) ;; exclude EMMS history from recent files
(setq dashboard-set-footer nil) ;; don't show footer and quotes
;; == navigation
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index d9286966..79d6ff41 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -27,7 +27,7 @@
;; - p: Copy absolute file path
;; - P: Print the file at point via CUPS
;; - S: Study — start an org-drill session on the .org file at point
-;; - M-S-d (Meta-Shift-d): DWIM shell commands menu
+;; - M-D (Meta-Shift-d): DWIM shell commands menu
;; - TAB: Toggle subtree expansion
;; - F11: Toggle sidebar view
@@ -403,16 +403,16 @@ Uses feh on X11, swww on Wayland."
("lx" "~/archive/lectures/" "lectures")
("mb" "/media/backup/" "backup directory")
("mx" "~/music/" "music")
- ("pdx" "~/projects/documents/" "project documents")
- ("pdl" "~/projects/danneel/" "project danneel")
- ("pcl" "~/projects/clipper/" "project clipper")
+ ("pdx" "~/projects/home/documents/" "documents area")
+ ("pdl" "~/projects/home/danneel/" "project danneel")
+ ("pcl" "~/projects/home/clipper/" "clipper area")
("pwk" "~/projects/work/" "project work")
- ("pl" "~/projects/elibrary/" "project elibrary")
- ("pf" "~/projects/finances/" "project finances")
- ("pjr" "~/projects/jr-estate/" "project jr-estate")
- ("phx" "~/projects/health/" "project health")
- ("phl" "~/projects/homelab/" "project homelab")
- ("pk" "~/projects/kit/" "project kit")
+ ("pl" "~/projects/home/elibrary/" "elibrary area")
+ ("pf" "~/projects/home/finances/" "project finances")
+ ("pjr" "~/projects/home/jr-estate/" "project jr-estate")
+ ("phx" "~/projects/home/health/" "health area")
+ ("phl" "~/projects/home/" "project home")
+ ("pk" "~/projects/home/kit/" "kit area")
("pn" "~/projects/nextjob/" "project nextjob")
("ps" ,(concat pix-dir "/screenshots/") "pictures screenshots")
("px" ,pix-dir "pictures directory")
diff --git a/modules/duet-config.el b/modules/duet-config.el
new file mode 100644
index 00000000..2dc7ad2e
--- /dev/null
+++ b/modules/duet-config.el
@@ -0,0 +1,19 @@
+;;; duet-config.el --- DUET dual-pane commander configuration -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; Personal configuration glue for the DUET package, developed locally at
+;; ~/code/duet. Keybindings, defcustom values, and connection storage live
+;; here; the package itself stays free of personal opinions.
+;;
+;; Not yet required from init.el — DUET is a pre-alpha skeleton. Wire it in
+;; once Stage 1 provides usable commands.
+
+;;; Code:
+
+(use-package duet
+ :load-path "~/code/duet"
+ :ensure nil
+ :commands (duet))
+
+(provide 'duet-config)
+;;; duet-config.el ends here
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index 57eea706..ad17ea91 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -98,6 +98,7 @@
;;; Code:
(require 'cl-lib)
+(require 'system-lib) ;; cj/confirm-strong (permanent file destruction confirm)
;; --------------------------- Password-file helpers ---------------------------
@@ -197,6 +198,18 @@ file list."
(replace-regexp-in-string "'" "'\\\\''" (expand-file-name f))))
files "\n"))
+(defun cj/dwim-shell--zip-single-file-command ()
+ "Return the zip command template for a single marked file.
+The archive is named =<fne>.zip=, not a reconstruction of the input filename
+\(which produced invalid archives, and a `foo.' name for a directory)."
+ "zip -r '<<fne>>.zip' '<<f>>'")
+
+(defun cj/dwim-shell--dated-backup-command ()
+ "Return the cp command template for a timestamped backup of marked file(s).
+The timestamp is interpolated here with `format-time-string' so it can't sit
+dead inside the shell's single quotes the way a literal =$(date ...)= did."
+ (format "cp -p '<<f>>' '<<f>>.%s.bak'" (format-time-string "%Y%m%d_%H%M%S")))
+
;; ----------------------------- Dwim Shell Command ----------------------------
(use-package dwim-shell-command
@@ -336,7 +349,7 @@ Otherwise, unzip it to an appropriately named subdirectory "
(interactive)
(dwim-shell-command-on-marked-files
"Zip" (if (eq 1 (seq-length (dwim-shell-command--files)))
- "zip -r '<<fne>>.<<e>>' '<<f>>'"
+ (cj/dwim-shell--zip-single-file-command)
"zip -r '<<archive.zip(u)>>' '<<*>>'")
:utils "zip"))
@@ -546,8 +559,8 @@ clipboard contents cannot inject shell commands."
(interactive)
(dwim-shell-command-on-marked-files
"Backup with date"
- "cp -p '<<f>>' '<<f>>.$(date +%Y%m%d_%H%M%S).bak'"
- :utils '("cp" "date")))
+ (cj/dwim-shell--dated-backup-command)
+ :utils '("cp")))
(defun cj/dwim-shell-commands-optimize-image-for-web ()
"Optimize image(s) for web (reduce file size)."
@@ -801,7 +814,7 @@ switching off the .7z format to gpg-wrapped tar."
Uses =shred -u= so the file is unlinked after overwriting, matching the
\"delete\" the command name and prompt promise."
(interactive)
- (when (yes-or-no-p "This will permanently destroy files. Continue? ")
+ (when (cj/confirm-strong "This will permanently destroy files. Continue? ")
(dwim-shell-command-on-marked-files
"Secure delete"
"shred -vfzu -n 3 '<<f>>'"
@@ -929,7 +942,7 @@ gpg: decryption failed: No pinentry"
;; Bind menu to keymaps after function is defined
(with-eval-after-load 'dired
- (keymap-set dired-mode-map "M-S-d" #'dwim-shell-commands-menu)) ;; was M-D, overrides kill-word
+ (keymap-set dired-mode-map "M-D" #'dwim-shell-commands-menu)) ;; Meta-Shift-d; matches the dirvish binding below
(with-eval-after-load 'dirvish
(keymap-set dirvish-mode-map "M-D" #'dwim-shell-commands-menu)))
diff --git a/modules/help-config.el b/modules/help-config.el
index ce9fd861..df27cbea 100644
--- a/modules/help-config.el
+++ b/modules/help-config.el
@@ -50,24 +50,34 @@
;; ------------------------------------ Info -----------------------------------
- (defun cj/open-with-info-mode ()
- "Open the current buffer's file in Info mode if it's a valid info file.
+(defun cj/--info-open-plan (modified-p save-confirmed-p)
+ "Decide how to open a buffer in Info given its MODIFIED-P state.
+SAVE-CONFIRMED-P is the answer to the save prompt, meaningful only when
+MODIFIED-P. Returns `open', `save-then-open', or `cancel'."
+ (cond ((not modified-p) 'open)
+ (save-confirmed-p 'save-then-open)
+ (t 'cancel)))
+
+(defun cj/open-with-info-mode ()
+ "Open the current buffer's file in Info mode if it's a valid info file.
Preserves any unsaved changes and checks if the file exists."
- (interactive)
- (let ((file-name (buffer-file-name)))
- (when file-name
- (if (and (file-exists-p file-name)
- (string-match-p "\\.info\\'" file-name))
- (progn
- (when (buffer-modified-p)
- (if (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? ")
- (save-buffer)
- (message "Operation canceled")
- (cl-return-from cj/open-with-info-mode)))
- (kill-buffer (current-buffer))
- (info file-name))
- (message "Not a valid info file: %s" file-name)))))
+ (interactive)
+ (let ((file-name (buffer-file-name)))
+ (when file-name
+ (if (and (file-exists-p file-name)
+ (string-match-p "\\.info\\'" file-name))
+ (let ((modified (buffer-modified-p)))
+ (pcase (cj/--info-open-plan
+ modified
+ (and modified
+ (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? ")))
+ ('cancel (message "Operation canceled"))
+ (plan
+ (when (eq plan 'save-then-open) (save-buffer))
+ (kill-buffer (current-buffer))
+ (info file-name))))
+ (message "Not a valid info file: %s" file-name)))))
(defun cj/browse-info-files ()
"Browse and open .info or .info.gz files from user-emacs-directory."
@@ -96,7 +106,6 @@ Preserves any unsaved changes and checks if the file exists."
(:map Info-mode-map
("m" . bookmark-set) ;; Rebind 'm' from Info-menu to bookmark-set
("M" . Info-menu)) ;; Move Info-menu to 'M' instead
- :preface
:init
;; Add personal info files BEFORE Info mode initializes
;; (let ((personal-info-dir (expand-file-name "assets/info" user-emacs-directory)))
@@ -104,11 +113,7 @@ Preserves any unsaved changes and checks if the file exists."
;; (setq Info-directory-list (list personal-info-dir))))
;; the above makes the directory the info list. the below adds it to the default list
;; (add-to-list 'Info-default-directory-list personal-info-dir)))
- :hook
- (info-mode . info-persist-history-mode)
- :config
- ;; Make .info files open with our custom function
- (add-to-list 'auto-mode-alist '("\\.info\\'" . cj/open-with-info-mode)))
+ )
(provide 'help-config)
;;; help-config.el ends here.
diff --git a/modules/linear-config.el b/modules/linear-config.el
deleted file mode 100644
index 8fbae30c..00000000
--- a/modules/linear-config.el
+++ /dev/null
@@ -1,58 +0,0 @@
-;;; linear-config.el --- Linear.app integration -*- lexical-binding: t; -*-
-;; author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-;;
-;; Layer: 3 (Domain Workflow).
-;; Category: D/P.
-;; Load shape: deferred (command-loaded).
-;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: none.
-;; Direct test load: no.
-;;
-;; Near-vanilla pearl setup: close to what pearl's README documents for a
-;; first-time install (local checkout instead of a package archive), with two
-;; deliberate tweaks layered on after dogfooding the out-of-box experience — a
-;; global C-; L prefix (see below) and the shorter assignee @-tag.
-;;
-;; pearl owns its own keymap. `pearl-mode' turns on automatically in any buffer
-;; pearl renders (it carries a `#+LINEAR-SOURCE' header) and binds the whole
-;; command surface under `pearl-keymap-prefix' (default "C-; L"). This config
-;; also binds that same `pearl-prefix-map' globally under C-; L (`:bind-keymap'),
-;; so the full command surface is reachable from any buffer; the first press
-;; autoloads pearl. `M-x pearl-menu' / `M-x pearl-list-issues' still work too.
-;;
-;; Authentication: the Linear personal API key is read from authinfo.gpg. Add:
-;; machine api.linear.app login apikey password lin_api_YOURKEYHERE
-;; Generate it in Linear: Settings -> Security & access -> Personal API keys.
-
-;;; Code:
-
-(use-package pearl
- :ensure nil ;; local checkout, not from an archive
- :load-path "~/code/pearl"
- :commands (pearl-menu pearl-list-issues pearl-create-issue pearl-run-linear-view)
- ;; Bind pearl's command map globally under C-; L, so the full surface is
- ;; reachable from any buffer (not only inside a pearl-rendered one). The
- ;; first press autoloads pearl; it's the same `pearl-prefix-map' that
- ;; `pearl-mode' binds in-buffer, so behavior is identical everywhere.
- :bind-keymap ("C-; L" . pearl-prefix-map)
- :custom
- (pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
- ;; Shorten the assignee @-tag to the first name only (e.g. @first instead of
- ;; @first_last), trading disambiguation for a tighter tag line.
- (pearl-assignee-tag-short t)
- ;; Optional defaults — uncomment and fill in to skip the prompts. Set them
- ;; HERE, at init level, not via M-x pearl-set-default-view /
- ;; pearl-set-default-team: those persist through `customize-save-variable',
- ;; and this config redirects `custom-file' to a throwaway temp file
- ;; (system-defaults.el), so a setter's value is discarded on the next
- ;; restart. These :custom lines re-apply on every startup instead.
- ;; (pearl-default-view "My active work") ;; the local view `C-; L l' opens
- ;; (pearl-default-team-id "9fca2cf6-390c-4102-a9ff-f94a4ed823c5") ;; DeepSat SE; skips the team prompt on create / by-project
- :config
- (setq pearl-api-key
- (auth-source-pick-first-password :host "api.linear.app")))
-
-(provide 'linear-config)
-;;; linear-config.el ends here
diff --git a/modules/mail-config.el b/modules/mail-config.el
index f71d6eeb..dfc0c4e0 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -48,6 +48,31 @@
(defvar message-send-mail-function nil)
(defvar message-sendmail-envelope-from nil)
+(declare-function mu4e-message-field "mu4e-message")
+
+;; Refile (archive) target dispatch. A per-context `mu4e-refile-folder' string
+;; is unsafe: mu4e context :vars are sticky, so a value set when one context is
+;; active leaks into a later context that doesn't set its own -- archiving one
+;; account's mail into another's folder. A single function evaluated per
+;; message at refile time avoids that. Only cmail has a real synced Archive
+;; folder; the Gmail-backed accounts (gmail, dmail) sync no archive maildir, so
+;; refiling them would move mail into an unsynced, server-invisible folder
+;; (silent loss) -- signal instead.
+(defun cj/mu4e--refile-folder-for-maildir (maildir)
+ "Return the refile (archive) folder for MAILDIR, or signal when none exists.
+MAILDIR is a mu4e :maildir string such as \"/cmail/INBOX\"."
+ (cond
+ ((not (stringp maildir))
+ (user-error "Cannot refile: message has no maildir"))
+ ((string-prefix-p "/cmail" maildir) "/cmail/Archive")
+ (t
+ (user-error "No archive folder syncs for this account; refile disabled to avoid moving mail into an unsynced folder"))))
+
+(defun cj/mu4e--refile-folder (msg)
+ "Refile-folder function for `mu4e-refile-folder'.
+Dispatch on MSG's maildir via `cj/mu4e--refile-folder-for-maildir'."
+ (cj/mu4e--refile-folder-for-maildir (and msg (mu4e-message-field msg :maildir))))
+
(defcustom cj/smtpmail-debug-enabled nil
"Non-nil means enable verbose SMTP transport debug logging.
@@ -217,7 +242,8 @@ Prompts user for the action when executing."
:vars '((user-mail-address . "c@cjennings.net")
(user-full-name . "Craig Jennings")
(mu4e-drafts-folder . "/cmail/Drafts")
- (mu4e-sent-folder . "/cmail/Sent")))
+ (mu4e-sent-folder . "/cmail/Sent")
+ (mu4e-trash-folder . "/cmail/Trash")))
(make-mu4e-context
:name "deepsat.com"
@@ -232,6 +258,12 @@ Prompts user for the action when executing."
(mu4e-starred-folder . "/dmail/Starred")
(mu4e-trash-folder . "/dmail/Trash")))))
+ ;; Refile target is computed per message (see `cj/mu4e--refile-folder'), not
+ ;; set per context, because mu4e context :vars are sticky and would leak one
+ ;; account's archive folder into another. cmail archives to /cmail/Archive;
+ ;; gmail/dmail signal rather than move mail into an unsynced folder.
+ (setq mu4e-refile-folder #'cj/mu4e--refile-folder)
+
(setq mu4e-maildir-shortcuts
'(("/cmail/Inbox" . ?i)
("/cmail/Sent" . ?s)
diff --git a/modules/markdown-config.el b/modules/markdown-config.el
index 4faa4474..16935425 100644
--- a/modules/markdown-config.el
+++ b/modules/markdown-config.el
@@ -21,7 +21,7 @@
("\\.md\\'" . markdown-mode)
("\\.markdown\\'" . markdown-mode))
:bind (:map markdown-mode-map
- ("<f2>" . markdown-preview)) ;; use same key as compile for consistency
+ ("<f2>" . cj/markdown-preview)) ;; use same key as compile for consistency
:init (setq markdown-command "multimarkdown"))
;; Register markdown as a known org-src-block language so `org-lint'
@@ -36,9 +36,7 @@
;; allows for live previews of your html
;; see: https://github.com/skeeto/impatient-mode
(use-package impatient-mode
- :defer t
- :config
- (setq imp-set-user-filter 'markdown-html))
+ :defer t)
;;;; --------------------- WIP: Markdown-Preview ---------------------
@@ -51,14 +49,14 @@ Idempotent: re-running while the server is already up is a no-op."
(message "markdown preview server running on http://localhost:8080/imp"))
;; the filter to apply to markdown before impatient-mode pushes it to the server
-(defun markdown-preview ()
+(defun cj/markdown-preview ()
"Open the current buffer as a live HTML preview at http://localhost:8080/imp.
The simple-httpd listener must already be running -- see
`cj/markdown-preview-server-start'. Starting a network listener as a
side effect of opening a preview is surprising, so the server start
lives in a separate command."
(interactive)
- (unless (and (boundp 'httpd-process) httpd-process)
+ (unless (httpd-running-p)
(user-error "markdown preview server not running; run `M-x cj/markdown-preview-server-start' first"))
(impatient-mode 1)
(setq imp-user-filter #'cj/markdown-html)
diff --git a/modules/music-config.el b/modules/music-config.el
index fd619d8c..799db133 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -95,6 +95,7 @@
(require 'user-constants)
(require 'keybindings) ;; provides cj/custom-keymap
(require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle)
+(require 'system-lib) ;; cj/confirm-strong (overwrite confirms)
;;; Settings (no Customize)
@@ -371,7 +372,7 @@ Offers completion over existing names but allows new names."
(filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u")))
(full (expand-file-name filename cj/music-m3u-root)))
(when (and (file-exists-p full)
- (not (yes-or-no-p (format "Overwrite %s? " filename))))
+ (not (cj/confirm-strong (format "Overwrite %s? " filename))))
(user-error "Aborted saving playlist"))
(with-current-buffer (cj/music--ensure-playlist-buffer)
(let ((emms-source-playlist-ask-before-overwrite nil))
@@ -924,7 +925,7 @@ For URL tracks: decoded URL."
(file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root))
(content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
(when (and (file-exists-p file)
- (not (yes-or-no-p (format "Overwrite %s? " (file-name-nondirectory file)))))
+ (not (cj/confirm-strong (format "Overwrite %s? " (file-name-nondirectory file)))))
(user-error "Aborted creating radio station"))
(with-temp-file file
(insert content))
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index 43b42b5e..393f1d97 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -42,6 +42,8 @@
(declare-function org-get-heading "org")
(declare-function org-parse-time-string "org")
(declare-function pdf-view-active-region-text "pdf-view")
+(declare-function projectile-project-root "projectile" (&optional dir))
+(defvar inbox-file)
(defvar cj/org-capture--file-headline-target-cache (make-hash-table :test #'equal)
"Cache Org capture file+headline target markers by expanded file and headline.")
@@ -132,6 +134,88 @@ re-scanning large target files after the first successful lookup."
(advice-add 'org-capture-set-target-location
:around #'cj/org-capture--set-target-location-advice))
+;; ----------------------- Project-Aware Capture Target ------------------------
+;; C-c c t (Task) and C-c c b (Bug) file into the current projectile project's
+;; todo.org under its "... Open Work" heading. Outside a project they fall back
+;; to the global inbox; in a project with no todo.org they fall back to the
+;; inbox with a warning (they never create a project's todo.org).
+
+(defconst cj/--org-open-work-heading-regexp
+ "^\\*[ \t]+.*Open Work\\(?:[ \t]+:[^\n]*:\\)?[ \t]*$"
+ "Regexp matching a top-level \"... Open Work\" Org heading line.")
+
+(defun cj/--org-capture-project-name (root)
+ "Return a display project name for ROOT directory, or nil.
+The basename of ROOT with a single leading dot stripped and the first
+letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"."
+ (when (and (stringp root) (not (string-empty-p root)))
+ (let* ((base (file-name-nondirectory (directory-file-name root)))
+ (clean (if (and (> (length base) 1) (eq ?. (aref base 0)))
+ (substring base 1)
+ base)))
+ (and (not (string-empty-p clean))
+ (concat (upcase (substring clean 0 1)) (substring clean 1))))))
+
+(defun cj/--org-capture-project-target (root inbox)
+ "Pure capture-target decision for project-aware capture.
+ROOT is the projectile project root (or nil); INBOX is the global inbox
+file path. Return a plist (:file F :open-work BOOL :project NAME :warn MSG):
+- ROOT with a todo.org -> F is that todo.org, :open-work t.
+- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names the project.
+- ROOT nil -> F is INBOX, :open-work nil, :warn nil."
+ (if (and (stringp root) (not (string-empty-p root)))
+ (let ((todo (expand-file-name "todo.org" root))
+ (name (cj/--org-capture-project-name root)))
+ (if (file-exists-p todo)
+ (list :file todo :open-work t :project name :warn nil)
+ (list :file inbox :open-work nil :project name
+ :warn (format "No todo.org in project \"%s\"; captured to the inbox instead"
+ name))))
+ (list :file inbox :open-work nil :project nil :warn nil)))
+
+(defun cj/--org-capture-goto-open-work (project-name)
+ "Move point to a top-level \"... Open Work\" heading in the current buffer.
+Create \"* PROJECT-NAME Open Work\" at end of buffer when none exists.
+Leave point at the start of the heading line."
+ (goto-char (point-min))
+ (if (re-search-forward cj/--org-open-work-heading-regexp nil t)
+ (forward-line 0)
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert (format "* %s Open Work\n" project-name))
+ (forward-line -1)))
+
+(defun cj/--org-capture-goto-exact-headline (headline)
+ "Move point to the top-level HEADLINE in the current buffer.
+Create \"* HEADLINE\" at end of buffer when absent. Leave point at the
+start of the heading line."
+ (goto-char (point-min))
+ (if (re-search-forward (format org-complex-heading-regexp-format
+ (regexp-quote headline))
+ nil t)
+ (forward-line 0)
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert "* " headline "\n")
+ (forward-line -1)))
+
+(defun cj/--org-capture-project-location ()
+ "Org-capture `function' target for project-aware Task/Bug capture.
+File into the current projectile project's todo.org under its \"... Open
+Work\" heading, else the global inbox (`inbox-file') under \"Inbox\"."
+ (let* ((root (and (fboundp 'projectile-project-root)
+ (ignore-errors (projectile-project-root))))
+ (plan (cj/--org-capture-project-target root inbox-file)))
+ (when (plist-get plan :warn)
+ (message "%s" (plist-get plan :warn)))
+ (set-buffer (org-capture-target-buffer (plist-get plan :file)))
+ (unless (derived-mode-p 'org-mode) (org-mode))
+ (org-capture-put-target-region-and-position)
+ (widen)
+ (if (plist-get plan :open-work)
+ (cj/--org-capture-goto-open-work (plist-get plan :project))
+ (cj/--org-capture-goto-exact-headline "Inbox"))))
+
;; --------------------------- Org-Capture Templates ---------------------------
;; you can bring up the org capture menu with C-c c
@@ -201,9 +285,12 @@ Intended to be called within an org capture template."
;; ORG-CAPTURE TEMPLATES
(setq org-protocol-default-template-key "L")
(setq org-capture-templates
- '(("t" "Task" entry (file+headline inbox-file "Inbox")
+ '(("t" "Task" entry (function cj/--org-capture-project-location)
"* TODO %?" :prepend t)
+ ("b" "Bug" entry (function cj/--org-capture-project-location)
+ "* TODO [#C] %?" :prepend t)
+
("e" "Event" entry (file+headline schedule-file "Scheduled Events")
"* %?%:description
SCHEDULED: %^t%(cj/org-capture-event-content)
@@ -257,5 +344,140 @@ Captured On: %U" :prepend t)
)) ;; end setq
) ;; end use-package org-protocol
+;; ---------------------- Popup Capture Frame Auto-Close ----------------------
+;; The quick-capture script (Hyprland Super+Shift+N) opens an emacsclient
+;; frame named "org-capture"; Hyprland window rules float and center it by
+;; that name. These hooks close the frame when the capture finalizes or
+;; aborts, so the popup never lingers. Frames not named "org-capture" are
+;; untouched — normal in-Emacs captures keep their windows.
+
+(defun cj/org-capture--popup-frame-p ()
+ "Return non-nil when the selected frame is the quick-capture popup."
+ (equal (frame-parameter nil 'name) "org-capture"))
+
+(defun cj/org-capture--delete-popup-frame ()
+ "Delete the current frame when it is the quick-capture popup."
+ (when (cj/org-capture--popup-frame-p)
+ (delete-frame)))
+
+(add-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame)
+
+;; The popup opens a fresh emacsclient frame still showing the daemon's last
+;; buffer. `org-mks' shows the *Org Select* menu via
+;; `switch-to-buffer-other-window', and `org-capture-place-template' shows the
+;; CAPTURE-* buffer via `pop-to-buffer' with a split action — both split the
+;; small floating frame, so two reverse-video modelines read like tmux bars and
+;; the working buffer leaks into a popup that should only show capture UI. A
+;; frame-scoped `display-buffer-alist' entry forces both into the frame's sole
+;; window. Gated on the "org-capture" frame name, so normal in-Emacs captures
+;; keep their windows.
+
+(defun cj/org-capture--popup-sole-window-p (frame-name buffer-name)
+ "Return non-nil when BUFFER-NAME in a frame named FRAME-NAME is capture popup UI.
+Capture popup UI is the *Org Select* template menu or a CAPTURE-* buffer
+shown in the quick-capture frame (FRAME-NAME equal to \"org-capture\")."
+ (and (equal frame-name "org-capture")
+ (stringp buffer-name)
+ (or (equal buffer-name "*Org Select*")
+ (string-prefix-p "CAPTURE-" buffer-name))))
+
+(defun cj/org-capture--popup-display-condition (buffer-name &optional _action)
+ "`display-buffer' CONDITION matching capture UI in the quick-capture popup.
+BUFFER-NAME is the buffer's name; the selected frame supplies the frame name."
+ (cj/org-capture--popup-sole-window-p (frame-parameter nil 'name) buffer-name))
+
+(defun cj/org-capture--display-sole-window (buffer _alist)
+ "`display-buffer' ACTION showing BUFFER as the only window of the frame.
+Used for the quick-capture popup so the template menu and capture buffer
+never split the small floating frame."
+ (let ((window (frame-root-window)))
+ (delete-other-windows window)
+ (set-window-buffer window buffer)
+ window))
+
+(add-to-list 'display-buffer-alist
+ '(cj/org-capture--popup-display-condition
+ cj/org-capture--display-sole-window))
+
+;; The desktop quick-capture popup is launched globally (no browser selection,
+;; no mu4e message, no pdf/epub buffer), so most templates make no sense there:
+;; the context fields (%:link, %i) come up empty or point at the daemon's last
+;; buffer, and the pdf templates error outright. `cj/quick-capture' offers only
+;; Task, Bug, and Event; Task and Bug file to the global inbox rather than a
+;; project todo.org, since a desktop capture has no meaningful project context.
+;; It also closes the popup frame on every exit path (abort, error, finalize) —
+;; `org-capture' only runs `org-capture-after-finalize-hook' on a completed
+;; capture, so a q/C-g at the template menu or an erroring template would
+;; otherwise orphan the frame. The Hyprland script calls this instead of
+;; `org-capture'.
+
+(defun cj/--org-capture-popup-templates (templates inbox)
+ "Return the desktop-popup subset of TEMPLATES: Task, Bug, Event.
+Task (\"t\") and Bug (\"b\") are retargeted to INBOX's \"Inbox\" headline;
+Event (\"e\") passes through unchanged. All other templates are dropped.
+Template bodies and properties are preserved."
+ (delq nil
+ (mapcar
+ (lambda (entry)
+ (pcase (car-safe entry)
+ ((or "t" "b")
+ ;; (KEY DESC TYPE TARGET TEMPLATE . PROPS) -> retarget TARGET
+ (append (list (nth 0 entry) (nth 1 entry) (nth 2 entry)
+ (list 'file+headline inbox "Inbox"))
+ (nthcdr 4 entry)))
+ ("e" entry)
+ (_ nil)))
+ templates)))
+
+(defun cj/org-capture--popup-frame ()
+ "Return a live frame named \"org-capture\" (the quick-capture popup), or nil."
+ (seq-find (lambda (f)
+ (and (frame-live-p f)
+ (equal (frame-parameter f 'name) "org-capture")))
+ (frame-list)))
+
+(defun cj/quick-capture ()
+ "Org-capture entry point for the Hyprland desktop popup (frame \"org-capture\").
+Offers only Task, Bug, and Event; Task and Bug file to the global inbox.
+Closes the popup frame on abort or error so a stray selection never orphans it.
+
+Selects the \"org-capture\" frame by name before capturing rather than trusting
+the ambient selected frame: the launching =emacsclient -c -e= runs before
+Hyprland settles focus on the new float, so =(selected-frame)= is still the
+daemon's main frame and the capture would otherwise land there."
+ (interactive)
+ (let ((frame (cj/org-capture--popup-frame)))
+ (condition-case err
+ (progn
+ (when frame (select-frame-set-input-focus frame))
+ (let ((org-capture-templates
+ (cj/--org-capture-popup-templates org-capture-templates inbox-file)))
+ (org-capture)))
+ (quit (cj/org-capture--delete-popup-frame))
+ (error (message "Quick-capture: %s" (error-message-string err))
+ (cj/org-capture--delete-popup-frame)))))
+
+;; The template menu's "C — Customize org-capture-templates" special makes no
+;; sense in the desktop popup (it would open a Customize buffer in the floating
+;; frame). Strip it from the menu when the selection runs in the popup frame,
+;; keeping "q — Abort". `org-mks' is the menu primitive; advising it (gated on
+;; the frame name) catches the capture template selection without touching
+;; org-mks's other callers.
+
+(defun cj/--org-capture-popup-strip-specials (specials)
+ "Remove the \"C\" Customize entry from org-mks SPECIALS, keeping the rest.
+SPECIALS is the org-mks specials alist (e.g. the Customize and Abort entries)."
+ (delq nil (mapcar (lambda (s) (unless (equal (car-safe s) "C") s)) specials)))
+
+(defun cj/org-capture--popup-mks-advice (orig table title &optional prompt specials)
+ "Around-advice for `org-mks': hide the Customize special in the quick-capture popup.
+ORIG is the real `org-mks'; TABLE TITLE PROMPT SPECIALS are its arguments."
+ (funcall orig table title prompt
+ (if (cj/org-capture--popup-frame-p)
+ (cj/--org-capture-popup-strip-specials specials)
+ specials)))
+
+(advice-add 'org-mks :around #'cj/org-capture--popup-mks-advice)
+
(provide 'org-capture-config)
;;; org-capture-config.el ends here.
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
index 296b0550..2c6e400e 100644
--- a/modules/org-drill-config.el
+++ b/modules/org-drill-config.el
@@ -95,9 +95,12 @@ With a prefix arg OTHER-DIR, prompt for the directory instead of `drill-dir'."
(defun cj/drill-refile ()
"Refile to a drill file."
(interactive)
- (setq org-refile-targets '((nil :maxlevel . 1)
- (drill-dir :maxlevel . 1)))
- (call-interactively 'org-refile))
+ (let ((org-refile-targets
+ `((nil :maxlevel . 1)
+ (,(mapcar (lambda (f) (expand-file-name f drill-dir))
+ (cj/--drill-files-or-error drill-dir))
+ :maxlevel . 1))))
+ (call-interactively 'org-refile)))
;; ------------------------------- Drill Keymap --------------------------------
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index fdd9e1fc..218f37d6 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -29,6 +29,12 @@
;; ---------------------------------- Org Roam ---------------------------------
+(defconst cj/--org-roam-dailies-head
+ "#+FILETAGS: Journal\n#+TITLE: %<%Y-%m-%d>\n"
+ "Head inserted into a new org-roam daily file.
+FILETAGS and TITLE must sit on separate lines so Org parses the
+#+TITLE keyword (see `org-roam-dailies-capture-templates').")
+
(use-package org-roam
:defer 1
:commands (org-roam-node-find org-roam-node-insert org-roam-db-autosync-mode)
@@ -37,9 +43,9 @@
(org-roam-dailies-directory journals-dir)
(org-roam-completion-everywhere t)
(org-roam-dailies-capture-templates
- '(("d" "default" entry "* %<%I:%M:%S %p %Z> %?"
+ `(("d" "default" entry "* %<%I:%M:%S %p %Z> %?"
:if-new (file+head "%<%Y-%m-%d>.org"
- "#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>"))))
+ ,cj/--org-roam-dailies-head))))
(org-roam-capture-templates
`(("d" "default" plain "%?"
diff --git a/modules/pearl-config.el b/modules/pearl-config.el
new file mode 100644
index 00000000..52994219
--- /dev/null
+++ b/modules/pearl-config.el
@@ -0,0 +1,66 @@
+;;; pearl-config.el --- Linear.app integration via pearl -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/P.
+;; Load shape: deferred (command-loaded).
+;; Top-level side effects: package configuration via use-package.
+;; Runtime requires: none.
+;; Direct test load: no.
+;;
+;; Near-vanilla pearl setup (local checkout instead of a package archive), in
+;; multi-account mode: two Linear workspaces, deepsat (work) and craigjennings
+;; (personal), named by Linear's own urlKey. Each account renders to its own
+;; Org file, deepsat.pearl.org / craigjennings.pearl.org, so they never collide.
+;; `M-x pearl-switch-account' swaps the active one; the mode line shows it.
+;;
+;; pearl owns its own keymap. `pearl-mode' turns on automatically in any buffer
+;; pearl renders (it carries a `#+LINEAR-SOURCE' header) and binds the whole
+;; command surface under `pearl-keymap-prefix' (default "C-; L"). This config
+;; also binds that same `pearl-prefix-map' globally under C-; L (`:bind-keymap'),
+;; so the full command surface is reachable from any buffer; the first press
+;; autoloads pearl. `M-x pearl-menu' / `M-x pearl-list-issues' still work too.
+;;
+;; Authentication: each account reads its key from authinfo.gpg by a distinct
+;; login under the api.linear.app host:
+;; machine api.linear.app login apikey password lin_api_<deepsat key>
+;; machine api.linear.app login pearl-personal password lin_api_<personal key>
+;; Generate keys in Linear: Settings -> Security & access -> Personal API keys.
+
+;;; Code:
+
+(use-package pearl
+ :ensure nil ;; local checkout, not from an archive
+ :load-path "~/code/pearl"
+ :commands (pearl-menu pearl-list-issues pearl-create-issue
+ pearl-run-linear-view pearl-switch-account)
+ ;; Bind pearl's command map globally under C-; L, so the full surface is
+ ;; reachable from any buffer (not only inside a pearl-rendered one). The
+ ;; first press autoloads pearl; it's the same `pearl-prefix-map' that
+ ;; `pearl-mode' binds in-buffer, so behavior is identical everywhere.
+ :bind-keymap ("C-; L" . pearl-prefix-map)
+ :custom
+ ;; Shorten the assignee @-tag to the first name only (e.g. @first instead of
+ ;; @first_last), trading disambiguation for a tighter tag line.
+ (pearl-assignee-tag-short t)
+ ;; Two workspaces, keyed by Linear's urlKey. Each resolves its API key from
+ ;; authinfo.gpg by its own login (see Commentary), renders to its own Org
+ ;; file, and carries a default team so create / by-project skip the prompt.
+ (pearl-accounts
+ '(("deepsat"
+ :api-key-source (:auth-source :host "api.linear.app" :user "apikey")
+ :org-file "~/org/gtd/deepsat.pearl.org"
+ :default-team-id "9fca2cf6-390c-4102-a9ff-f94a4ed823c5") ;; DeepSat SE
+ ("craigjennings"
+ :api-key-source (:auth-source :host "api.linear.app" :user "pearl-personal")
+ :org-file "~/org/gtd/craigjennings.pearl.org"
+ :default-team-id "ee285e6c-fcc9-4dd6-9292-c47f2df75b82"))) ;; Pearl
+ ;; Which workspace pearl opens into. Work is primary; switch per-session at
+ ;; runtime with `M-x pearl-switch-account' (e.g. to dogfood the personal
+ ;; "craigjennings" workspace).
+ (pearl-default-account "deepsat"))
+
+(provide 'pearl-config)
+;;; pearl-config.el ends here
diff --git a/modules/prog-general.el b/modules/prog-general.el
index a4be7205..8b4dedda 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -298,6 +298,22 @@ This is what makes universal snippets like =<cj= work in any buffer."
(yas-reload-all)
(yas-global-mode 1))
+;; Most of the snippet keys start with "<" (=<cj=, =<for=, =<main=…), mirroring
+;; org-tempo. But `electric-pair-mode' pairs "<" into "<>" wherever the mode's
+;; syntax table gives "<" paren syntax (org, and the prog modes that enable
+;; pairing), so typing "<cj" lands as "<cj>"; expanding the "<cj" key then
+;; strands the ">" after the snippet — the cj-comment fence comes out as
+;; "#+end_src>", which breaks the cj-scan fence parser. Inhibit pairing for the
+;; open angle bracket globally; defer to the default for every other character.
+(defun cj/--electric-pair-inhibit-angle (char)
+ "Return non-nil to stop `electric-pair-mode' from pairing the angle CHAR.
+Inhibit the open angle bracket so \"<\"-prefixed yasnippet keys expand cleanly;
+defer to `electric-pair-default-inhibit' for any other CHAR."
+ (or (eq char ?<)
+ (electric-pair-default-inhibit char)))
+
+(setq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle)
+
;; --------------------- Display Color On Color Declaration --------------------
;; display the actual color as highlight to color hex code
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
index dd82ef0f..79a895bf 100644
--- a/modules/reconcile-open-repos.el
+++ b/modules/reconcile-open-repos.el
@@ -171,8 +171,11 @@ Prunes generated/heavy directories. Once a repository root is found, do not
descend into it unless INCLUDE-NESTED is non-nil."
(let (repos)
(when (file-directory-p directory)
- (dolist (child (directory-files directory t "^[^.]+$" 'nosort))
+ (dolist (child (directory-files directory t directory-files-no-dot-files-regexp 'nosort))
(when (and (file-directory-p child)
+ ;; Skip hidden dirs (.git, .config) but keep dotted repo
+ ;; names like mcp.el; the old "^[^.]+$" filter dropped both.
+ (not (string-prefix-p "." (file-name-nondirectory child)))
(not (cj/reconcile--pruned-directory-p child)))
(if (file-directory-p (expand-file-name ".git" child))
(progn
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index 11687337..b136ad15 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -47,6 +47,11 @@
:init
(vertico-mode))
+;; Save each completion session so `vertico-repeat' (the second C-s in
+;; `cj/consult-line-or-repeat') has a session to resume. `vertico-repeat-save'
+;; is autoloaded, so this defers loading vertico-repeat until the first minibuffer.
+(add-hook 'minibuffer-setup-hook #'vertico-repeat-save)
+
(use-package marginalia
:demand t
:custom
@@ -246,6 +251,11 @@
(use-package vertico-prescient
:demand t
+ :custom
+ ;; orderless does the matching; prescient only sorts. Without this,
+ ;; vertico-prescient-mode's default filtering overrides completion-styles to
+ ;; prescient inside vertico sessions, leaving the orderless config above dead.
+ (vertico-prescient-enable-filtering nil)
:config
(vertico-prescient-mode))
diff --git a/modules/signal-config.el b/modules/signal-config.el
index 102ece86..7e980b62 100644
--- a/modules/signal-config.el
+++ b/modules/signal-config.el
@@ -16,6 +16,10 @@
;;; Code:
(require 'seq)
+(require 'keybindings) ;; provides cj/custom-keymap + cj/register-prefix-map
+(require 'system-lib) ;; for cj/executable-find-or-warn
+
+(declare-function notifications-notify "notifications")
(defun cj/signal--jstr (value)
"Return VALUE if it is a non-blank string, else nil.
@@ -101,6 +105,46 @@ window of a focused frame."
(buffer-name (window-buffer (selected-window)))
(cj/signal--frame-focused-p))))
+;;; Notifications
+
+(defcustom cj/signel-notify-sound nil
+ "When non-nil, incoming-message notifications play the notify script's sound.
+Nil (the default) passes --silent so the toast is visual only."
+ :type 'boolean
+ :group 'signel)
+
+(defconst cj/signal--notify-body-max 120
+ "Maximum character length of a desktop-notification body.
+Longer message text truncates to this length ending in an ellipsis;
+the full text is always in the chat buffer.")
+
+(defun cj/signal--format-notify-body (text)
+ "Collapse whitespace in TEXT and truncate it for a notification body.
+Whitespace runs (including newlines) become single spaces, the result
+is trimmed, and anything over `cj/signal--notify-body-max' characters
+truncates to that length with a trailing ellipsis."
+ (let ((flat (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " text))))
+ (if (<= (length flat) cj/signal--notify-body-max)
+ flat
+ (concat (substring flat 0 (1- cj/signal--notify-body-max)) "…"))))
+
+(defun cj/signel--notify (chat-id sender body)
+ "Raise a desktop notification for an incoming Signal message.
+Suppressed via `cj/signal--should-notify-p' when the user is actively
+viewing CHAT-ID. Routes through the external notify script when it is
+on PATH (type info, sound gated by `cj/signel-notify-sound'), falling
+back to `notifications-notify' otherwise. SENDER names the title;
+BODY is formatted by `cj/signal--format-notify-body'. Installed as
+`signel-notify-function' in the use-package :config below."
+ (when (cj/signal--should-notify-p chat-id)
+ (let ((title (format "Signal: %s" sender))
+ (text (cj/signal--format-notify-body body))
+ (script (executable-find "notify")))
+ (if script
+ (apply #'start-process "signel-notify" nil script "info" title text
+ (unless cj/signel-notify-sound (list "--silent")))
+ (notifications-notify :title title :body text)))))
+
;;; signel — fork integration
(defcustom cj/signal-private-config-file
@@ -125,7 +169,13 @@ time."
(signel-auto-open-buffer nil)
:config
(when (file-readable-p cj/signal-private-config-file)
- (load cj/signal-private-config-file nil t)))
+ (load cj/signal-private-config-file nil t))
+ ;; Route incoming-message notifications through cj/signel--notify
+ ;; (suppression + notify script + truncation); warn once at load when
+ ;; the script is missing — the runtime path still falls back to
+ ;; notifications-notify, so messages are never silently dropped.
+ (setq signel-notify-function #'cj/signel--notify)
+ (cj/executable-find-or-warn "notify" "Signal desktop notifications via the notify script (falling back to notifications-notify)" 'signal-config))
;; Chat buffers (named `*Signel: <id>*') open in the bottom 30% of the
;; frame rather than wherever display-buffer's fallback rule picks.
@@ -291,10 +341,11 @@ that on first use."
Leaves =l= unbound for now -- the future =cj/signel-link= command lands
in a later pass. See =docs/design/signal-client.org= scope summary.")
-(declare-function cj/custom-keymap "keybindings" ())
-(with-eval-after-load 'keybindings
- (when (boundp 'cj/custom-keymap)
- (keymap-set cj/custom-keymap "M" cj/signel-prefix-map)))
+;; Register the messages prefix under C-; M via the documented helper.
+;; keybindings.el owns cj/custom-keymap; the (require 'keybindings) above
+;; guarantees it is loaded before this runs, so no load-order guard is
+;; needed. This is the same pattern every other feature module uses.
+(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages")
(provide 'signal-config)
;;; signal-config.el ends here
diff --git a/modules/system-commands.el b/modules/system-commands.el
index dba4d40e..44ac3ae8 100644
--- a/modules/system-commands.el
+++ b/modules/system-commands.el
@@ -9,7 +9,7 @@
;; Eager reason: registers the C-; ! system-command keymap; high-impact commands
;; that should run only by command (command-loaded target).
;; Top-level side effects: defines a system-command keymap under cj/custom-keymap.
-;; Runtime requires: keybindings, rx.
+;; Runtime requires: keybindings, host-environment, rx.
;; Direct test load: yes (requires keybindings explicitly).
;;
;; System commands for logout, lock, suspend, shutdown, reboot, and Emacs
@@ -17,7 +17,7 @@
;;
;; Commands include:
;; - Logout (terminate user session)
-;; - Lock screen (slock)
+;; - Lock screen (hyprlock on Wayland, slock on X11)
;; - Suspend (systemctl suspend)
;; - Shutdown (systemctl poweroff)
;; - Reboot (systemctl reboot)
@@ -34,6 +34,14 @@
;; the load-time reference void if anything required `system-commands'
;; before `keybindings'. Make the dependency explicit.
(require 'keybindings)
+;; `host-environment' provides `env-wayland-p', referenced at load time by the
+;; `lockscreen-cmd' defvar below to pick the session-appropriate locker. A hard
+;; require keeps the module loadable on its own (tests, byte-compile) rather
+;; than relying on init.el's load order.
+(require 'host-environment)
+;; `system-lib' provides `cj/confirm-strong', used at runtime by the `strong'
+;; confirm branch of `cj/system-cmd' for irreversible actions (shutdown/reboot).
+(require 'system-lib)
(eval-when-compile (require 'subr-x))
(require 'rx)
@@ -71,7 +79,7 @@ If CMD is deemed dangerous, ask for confirmation."
;; Strong confirm for irreversible actions (shutdown, reboot):
;; require an explicit "yes", so a stray RET/space can't trigger them.
((eq confirm 'strong)
- (unless (yes-or-no-p (format "Really run %s (%s)? " label cmdstr))
+ (unless (cj/confirm-strong (format "Really run %s (%s)? " label cmdstr))
(user-error "Aborted")))
;; Quick (Y/n) confirm for recoverable actions (logout, suspend).
(confirm
@@ -102,7 +110,13 @@ actions like shutdown and reboot), nil for no confirmation."
;; Define system commands
(cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t)
-(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock")
+;; slock is X11-only and can't grab a Wayland session. On Wayland, lock via
+;; the session manager (`loginctl lock-session') rather than spawning a locker
+;; directly: logind emits the Lock signal, hypridle catches it and runs its
+;; lock_cmd (hyprlock), the same path idle/before-sleep locking already uses.
+;; X11 machines keep slock.
+(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd
+ (if (env-wayland-p) "loginctl lock-session" "slock"))
(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t)
(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" strong)
(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" strong)
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index eccc6c35..1703b1bf 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -200,8 +200,9 @@ appears only once per session."
(setq confirm-nonexistent-file-or-buffer nil) ;; don't ask if a file I visit with C-x C-f or C-x b doesn't exist
(setq ad-redefinition-action 'accept) ;; silence warnings about advised functions getting redefined.
(setq large-file-warning-threshold nil) ;; open files regardless of size
-(fset 'yes-or-no-p 'y-or-n-p) ;; require a single letter for binary answers
-(setq use-short-answers t) ;; same as above with Emacs 28+
+(setq use-short-answers t) ;; single-key y/n for ordinary yes-or-no-p prompts
+ ;; (irreversible actions use `cj/confirm-strong', which
+ ;; forces a typed "yes" by binding this nil for that call)
(setq auto-revert-verbose nil) ;; turn off auto revert messages
(setq custom-safe-themes t) ;; treat all themes as safe (stop asking)
(setq server-client-instructions nil) ;; I already know what to do when done with the frame
diff --git a/modules/system-lib.el b/modules/system-lib.el
index 333c15ee..9e25be5b 100644
--- a/modules/system-lib.el
+++ b/modules/system-lib.el
@@ -130,5 +130,16 @@ Callers that must have a secret layer their own error on top."
(secret (plist-get (car (apply #'auth-source-search spec)) :secret)))
(if (functionp secret) (funcall secret) secret)))
+;; ---------------------------- Strong Confirmation ----------------------------
+
+(defun cj/confirm-strong (prompt)
+ "Ask PROMPT, requiring a full typed \"yes\" or \"no\" answer.
+For irreversible actions -- file destruction, overwrites, power-off. The
+global default makes `yes-or-no-p' a single keystroke (`use-short-answers'
+is t); this binds it to nil for the one call so the prompt demands the
+long-form answer, keeping a stray RET or space from confirming."
+ (let ((use-short-answers nil))
+ (yes-or-no-p prompt)))
+
(provide 'system-lib)
;;; system-lib.el ends here
diff --git a/modules/term-config.el b/modules/term-config.el
index 5753edde..f9c12635 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -29,10 +29,12 @@
;; Two ways to lift text out of a terminal, both with the same key story:
;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux
;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux),
-;; sends tmux's prefix C-b [ so the user lands in tmux's own copy-mode with
-;; the full pane history available. Without tmux, falls back to
+;; sends tmux's prefix C-b [ then C-a, so the user lands in tmux's own
+;; copy-mode with the full pane history and the cursor at column 0 (so
+;; scrolling up runs up the left, not the right). Without tmux, falls back to
;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the
-;; scrollback; M-w copies and stays, q / C-g exit).
+;; scrollback; M-w copies and stays, q / C-g exit) and moves point to the
+;; start of the line for the same column-0 reason.
;; - C-; x h captures the current tmux pane's full history into a temporary
;; Emacs buffer.
;; In both copy surfaces, M-w copies the active region and stays open so several
@@ -190,13 +192,19 @@ cheap boolean predicate."
"Enter copy-mode using the engine appropriate to this terminal.
When tmux is attached, write tmux's default prefix sequence (C-b [) into the
-pty so the user lands in tmux's copy-mode with the full pane history. Without
-tmux, falls through to `ghostel-copy-mode', a read-only standard-Emacs view of
-the scrollback (M-w copies and stays, q / C-g exit)."
+pty so the user lands in tmux's copy-mode with the full pane history, then
+C-a to land the cursor at the start of the line. Without the trailing C-a
+the copy cursor inherits the live column (far right after a prompt) and
+scrolling up runs up the right edge; tmux's emacs copy-mode binds C-a to
+start-of-line, so column 0 makes it run up the left. Without tmux, falls
+through to `ghostel-copy-mode' (a read-only standard-Emacs view of the
+scrollback; M-w copies and stays, q / C-g exit), then moves point to the
+start of the line for the same column-0 reason."
(interactive)
(if (cj/term--in-tmux-p)
- (ghostel-send-string "\C-b[")
- (ghostel-copy-mode)))
+ (ghostel-send-string "\C-b[\C-a")
+ (ghostel-copy-mode)
+ (beginning-of-line)))
;; ----------------------------- ghostel package -------------------------------
@@ -229,9 +237,12 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; rebuild is what actually lets the key through to `ghostel-mode-map' / the
;; global map. C-; and F12 are the prefix + toggle; the modified arrows are
;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the
- ;; ai-term workflow expects to work from inside an agent buffer.
+ ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and
+ ;; C-F10 are global bindings (org agenda, music-playlist toggle, server
+ ;; shutdown) that reach Emacs by falling through to the global map once the
+ ;; semi-char map stops forwarding them.
(with-eval-after-load 'ghostel
- (dolist (key '("C-;" "<f12>"
+ (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>"
"S-<up>" "S-<down>" "S-<left>" "S-<right>"
"C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
(add-to-list 'ghostel-keymap-exceptions key))
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
index f1324c16..f2181d97 100644
--- a/modules/ui-navigation.el
+++ b/modules/ui-navigation.el
@@ -160,7 +160,9 @@ This function won't work with more than one split window."
;; UNDO KILL BUFFER
(defun cj/undo-kill-buffer (arg)
- "Re-open the last buffer killed. With ARG, re-open the nth buffer."
+ "Re-open the last buffer killed.
+With numeric prefix ARG, re-open the ARGth most-recently-killed file
+\(1-based, so no prefix re-opens the most recent)."
(interactive "p")
(require 'recentf)
(unless recentf-mode
@@ -177,9 +179,11 @@ This function won't work with more than one split window."
(delq buf-file recently-killed-list)))
buffer-files-list)
(when recently-killed-list
- (find-file
- (if arg (nth arg recently-killed-list)
- (car recently-killed-list))))))
+ (let ((file (nth (1- arg) recently-killed-list)))
+ (if file
+ (find-file file)
+ (user-error "Only %d killed file(s) to choose from"
+ (length recently-killed-list)))))))
(keymap-global-set "M-S-z" #'cj/undo-kill-buffer) ;; was M-Z, overrides zap-to-char
;; ---------------------------- Undo Layout Changes ----------------------------
diff --git a/modules/user-constants.el b/modules/user-constants.el
index 43a23d79..2e64b355 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -125,8 +125,10 @@ fallback only.")
(defconst org-dir (expand-file-name "org/" sync-dir)
"This directory is synchronized across machines.")
-(defconst roam-dir (expand-file-name "roam/" org-dir)
- "The location of org-roam files.")
+(defconst roam-dir (expand-file-name "org/roam/" user-home-dir)
+ "The location of org-roam files.
+A standalone git repo (cjennings.net:roam.git), no longer inside the
+Syncthing-synced `org-dir' — see the 2026-06-10 transport migration.")
(defconst journals-dir (expand-file-name "journal/" roam-dir)
"The location of org-roam dailies or journals files.")
@@ -149,7 +151,7 @@ fallback only.")
(defconst music-dir (expand-file-name "music/" user-home-dir)
"The location to save your music files.")
-(defconst website-dir (expand-file-name "projects/website/" user-home-dir)
+(defconst website-dir (expand-file-name "code/website/" user-home-dir)
"Root directory of the Hugo website project.")