From 77135d84180acd38505f5a51790760dd955e66e3 Mon Sep 17 00:00:00 2001 From: phasetr Date: Sat, 16 May 2026 20:23:33 +0900 Subject: [PATCH 1/6] chore: start tmux pane target fix From 7236dbb020fc37206a3abc3285110895ad1b6ce4 Mon Sep 17 00:00:00 2001 From: phasetr Date: Sat, 16 May 2026 20:31:29 +0900 Subject: [PATCH 2/6] fix: target tmux panes by stable pane id --- enkan-repl-terminal.el | 151 ++++++++++++++++++++++--------- enkan-repl.el | 43 ++++++--- test/enkan-repl-core-test.el | 80 +++++++++------- test/enkan-repl-terminal-test.el | 21 ++++- 4 files changed, 201 insertions(+), 94 deletions(-) diff --git a/enkan-repl-terminal.el b/enkan-repl-terminal.el index fe777bd..284627d 100644 --- a/enkan-repl-terminal.el +++ b/enkan-repl-terminal.el @@ -368,18 +368,32 @@ or nil on non-zero exit. Otherwise return t on zero exit, nil on non-zero." (defun enkan-repl--terminal-tmux--list-window-cwds (session) "Return list of (WINDOW . CWD) pairs in tmux SESSION." + (mapcar + (lambda (info) + (cons (plist-get info :window) + (plist-get info :cwd))) + (enkan-repl--terminal-tmux--list-window-info session))) + +(defun enkan-repl--terminal-tmux--list-window-info (session) + "Return plist entries describing tmux windows in SESSION. +Each entry contains :window, :cwd, and :pane. :pane is the stable tmux +pane id (for example, \"%12\") used for command targets." (let ((out (enkan-repl--terminal-tmux--call (list "list-windows" "-t" session "-F" - "#{window_name}\t#{pane_current_path}") + "#{window_name}\t#{pane_current_path}\t#{pane_id}") t))) (when (and out (not (string-empty-p out))) (delq nil (mapcar (lambda (line) - (pcase-let ((`(,window ,cwd) (split-string line "\t"))) - (when (and window cwd (not (string-empty-p window))) - (cons window (unless (string-empty-p cwd) cwd))))) + (pcase-let ((`(,window ,cwd ,pane) (split-string line "\t"))) + (when (and window (not (string-empty-p window))) + (list :window window + :cwd (unless (string-empty-p cwd) cwd) + :pane (and pane + (not (string-empty-p pane)) + pane))))) (split-string out "\n" t)))))) (defun enkan-repl--terminal-tmux--derive-base-name (dir) @@ -399,13 +413,26 @@ Tries BASE, then BASE-2, BASE-3, ... until an unused name is found." (setq candidate (format "%s-%d" base n))) candidate)) -(defun enkan-repl--terminal-tmux--make-id (session window) - "Return tmux target identifier for SESSION:WINDOW." - (format "%s:%s" session window)) +(defun enkan-repl--terminal-tmux--make-id (session window &optional pane) + "Return enkan tmux identifier for SESSION, WINDOW, and optional PANE. +When PANE is available, the identifier keeps WINDOW for Emacs-side metadata +but tmux commands target the stable PANE id. This avoids tmux parsing window +names such as dr-remote.jp as pane selectors." + (if (and (stringp pane) (not (string-empty-p pane))) + (format "%s:%s|%s" session window pane) + (format "%s:%s" session window))) (defun enkan-repl--terminal-tmux--id-window (id) - "Return the window component of tmux ID (after the colon)." + "Return the window component of tmux ID." (when (and (stringp id) (string-match ":\\(.+\\)$" id)) + (let ((window (match-string 1 id))) + (if (string-match "\\`\\(.*\\)|%[0-9]+\\'" window) + (match-string 1 window) + window)))) + +(defun enkan-repl--terminal-tmux--id-pane (id) + "Return the pane id component of tmux ID, or nil for legacy IDs." + (when (and (stringp id) (string-match "|\\(%[0-9]+\\)\\'" id)) (match-string 1 id))) (defun enkan-repl--terminal-tmux--id-session (id) @@ -413,6 +440,11 @@ Tries BASE, then BASE-2, BASE-3, ... until an unused name is found." (when (and (stringp id) (string-match "^\\([^:]+\\):" id)) (match-string 1 id))) +(defun enkan-repl--terminal-tmux--target (id) + "Return the actual tmux target for enkan tmux ID." + (or (enkan-repl--terminal-tmux--id-pane id) + id)) + (defun enkan-repl--terminal-tmux-start (dir) "Tmux backend: start a new session/window in DIR for current workspace. Ensures the workspace's tmux session exists (creating it on demand) and @@ -426,27 +458,37 @@ target identifier (e.g. \"enkan-01:lat\")." (cond ;; Session does not exist: create it with the first window in DIR. ((not (enkan-repl--terminal-tmux--has-session session)) - (unless (enkan-repl--terminal-tmux--call - (list "new-session" "-d" "-s" session "-c" cdir "-n" base)) - (user-error "Tmux new-session failed for %s" session)) - (enkan-repl--terminal-tmux--make-id session base)) + (let ((pane (enkan-repl--terminal-tmux--call + (list "new-session" "-d" "-P" "-F" "#{pane_id}" + "-s" session "-c" cdir "-n" base) + t))) + (unless (and pane (not (string-empty-p pane))) + (user-error "Tmux new-session failed for %s" session)) + (enkan-repl--terminal-tmux--make-id session base pane))) ;; Session exists: add a new window with a non-colliding name. (t (let ((win (enkan-repl--terminal-tmux--next-instance-name session base))) - (unless (enkan-repl--terminal-tmux--call - (list "new-window" "-t" session "-n" win "-c" cdir)) - (user-error "Tmux new-window failed for %s:%s" session win)) - (enkan-repl--terminal-tmux--make-id session win)))))) + (let ((pane (enkan-repl--terminal-tmux--call + (list "new-window" "-P" "-F" "#{pane_id}" + "-t" session "-n" win "-c" cdir) + t))) + (unless (and pane (not (string-empty-p pane))) + (user-error "Tmux new-window failed for %s:%s" session win)) + (enkan-repl--terminal-tmux--make-id session win pane))))))) (defun enkan-repl--terminal-tmux-send (id text &optional newline) "Tmux backend: send TEXT to ID via send-keys -l. When NEWLINE is non-nil, follow with an Enter key. Returns t on success." (when (and id text) (and (enkan-repl--terminal-tmux--call - (list "send-keys" "-t" id "-l" text)) + (list "send-keys" "-t" + (enkan-repl--terminal-tmux--target id) + "-l" text)) (or (not newline) (enkan-repl--terminal-tmux--call - (list "send-keys" "-t" id "Enter")))))) + (list "send-keys" "-t" + (enkan-repl--terminal-tmux--target id) + "Enter")))))) (defun enkan-repl--terminal-tmux-send-key (id key) "Tmux backend: send special KEY (`escape', `enter', integer 1..9) to ID." @@ -455,29 +497,42 @@ When NEWLINE is non-nil, follow with an Enter key. Returns t on success." ('enter "Enter") ((pred integerp) (number-to-string key)) (_ (user-error "Unsupported terminal key: %S" key))))) - (enkan-repl--terminal-tmux--call (list "send-keys" "-t" id arg)))) + (enkan-repl--terminal-tmux--call + (list "send-keys" "-t" (enkan-repl--terminal-tmux--target id) arg)))) (defun enkan-repl--terminal-tmux-alive-p (id) "Tmux backend: t if SESSION:WINDOW described by ID still exists." (when id (let ((session (enkan-repl--terminal-tmux--id-session id)) + (pane (enkan-repl--terminal-tmux--id-pane id)) (window (enkan-repl--terminal-tmux--id-window id))) - (and session window + (and session (enkan-repl--terminal-tmux--has-session session) - (member window (enkan-repl--terminal-tmux--list-windows session)) + (if pane + (enkan-repl--terminal-tmux--call + (list "display-message" "-p" "-t" pane "#{pane_id}") + t) + (and window + (member window + (enkan-repl--terminal-tmux--list-windows session)))) t)))) (defun enkan-repl--terminal-tmux-list () "Tmux backend: list all window identifiers in the current workspace's session." (let ((session (enkan-repl--terminal-tmux--workspace-session))) (when (and session (enkan-repl--terminal-tmux--has-session session)) - (mapcar (lambda (w) (enkan-repl--terminal-tmux--make-id session w)) - (enkan-repl--terminal-tmux--list-windows session))))) + (mapcar (lambda (info) + (enkan-repl--terminal-tmux--make-id + session + (plist-get info :window) + (plist-get info :pane))) + (enkan-repl--terminal-tmux--list-window-info session))))) (defun enkan-repl--terminal-tmux-kill (id) "Tmux backend: kill the window described by ID." (when id - (enkan-repl--terminal-tmux--call (list "kill-window" "-t" id)))) + (enkan-repl--terminal-tmux--call + (list "kill-window" "-t" (enkan-repl--terminal-tmux--target id))))) ;;;;; tmux mirror buffer @@ -741,24 +796,31 @@ tmux capture process." 0.2)) (out (enkan-repl--terminal-tmux--call (list "list-windows" "-t" session "-F" - "#{window_name}\t#{window_bell_flag}") + "#{window_name}\t#{pane_id}\t#{window_bell_flag}") t))) (when (stringp out) (delq nil (mapcar (lambda (line) - (pcase-let ((`(,window ,flag) (split-string line "\t"))) - (when (and window (string= flag "1")) - (enkan-repl--terminal-tmux--make-id session window)))) + (let ((fields (split-string line "\t"))) + (pcase fields + (`(,window ,pane ,flag) + (when (and window (string= flag "1")) + (enkan-repl--terminal-tmux--make-id session window pane))) + (`(,window ,flag) + (when (and window (string= flag "1")) + (enkan-repl--terminal-tmux--make-id session window)))))) (split-string out "\n" t)))))) (defun enkan-repl--terminal-tmux--all-targets (session) "Return all tmux target ids in SESSION." - (let ((windows (enkan-repl--terminal-tmux--list-windows session))) - (mapcar (lambda (window) - (enkan-repl--terminal-tmux--make-id session window)) - windows))) + (mapcar (lambda (info) + (enkan-repl--terminal-tmux--make-id + session + (plist-get info :window) + (plist-get info :pane))) + (enkan-repl--terminal-tmux--list-window-info session))) (defun enkan-repl--terminal-tmux--alert-capture (target) "Return a small bounded capture from TARGET for alert detection." @@ -777,7 +839,7 @@ tmux capture process." (out (enkan-repl--terminal-tmux--call (list "capture-pane" "-p" "-J" "-S" (format "-%d" lines) - "-t" target) + "-t" (enkan-repl--terminal-tmux--target target)) t))) (when (stringp out) (if (and max-chars (> (length out) max-chars)) @@ -1112,7 +1174,8 @@ CALLBACK receives the cwd string, or nil on failure." (user-error "Tmux executable not found: %s" enkan-repl-tmux-executable)) (let* ((output-buffer (generate-new-buffer " *enkan-repl tmux cwd*")) (command (list enkan-repl-tmux-executable - "display-message" "-p" "-t" id + "display-message" "-p" "-t" + (enkan-repl--terminal-tmux--target id) "#{pane_current_path}"))) (make-process :name (format "enkan-tmux-cwd %s" id) @@ -1285,7 +1348,7 @@ arguments: CONTENT, or nil on failure, and the tmux process exit status." (let* ((command (list enkan-repl-tmux-executable "capture-pane" "-p" "-J" "-S" (format "-%d" (max 0 lines)) - "-t" id)) + "-t" (enkan-repl--terminal-tmux--target id))) (max-chars (and (integerp enkan-repl-tmux-mirror-max-chars) (> enkan-repl-tmux-mirror-max-chars 0) enkan-repl-tmux-mirror-max-chars)) @@ -1295,15 +1358,15 @@ arguments: CONTENT, or nil on failure, and the tmux process exit status." process) (cl-labels ((finish - (status) - (unless done - (setq done t) - (when timer - (cancel-timer timer) - (setq timer nil)) - (funcall callback - (and (integerp status) (zerop status) content) - status)))) + (status) + (unless done + (setq done t) + (when timer + (cancel-timer timer) + (setq timer nil)) + (funcall callback + (and (integerp status) (zerop status) content) + status)))) (setq process (make-process :name (format "enkan-tmux-capture %s" id) diff --git a/enkan-repl.el b/enkan-repl.el index 0517876..01fd66e 100644 --- a/enkan-repl.el +++ b/enkan-repl.el @@ -104,7 +104,8 @@ (declare-function enkan-repl--terminal-tmux--mirror-make "enkan-repl-terminal" (id &optional defer-refresh path)) (declare-function enkan-repl--terminal-tmux--list-windows "enkan-repl-terminal" (session)) (declare-function enkan-repl--terminal-tmux--list-window-cwds "enkan-repl-terminal" (session)) -(declare-function enkan-repl--terminal-tmux--make-id "enkan-repl-terminal" (session window)) +(declare-function enkan-repl--terminal-tmux--list-window-info "enkan-repl-terminal" (session)) +(declare-function enkan-repl--terminal-tmux--make-id "enkan-repl-terminal" (session window &optional pane)) (declare-function enkan-repl--terminal-tmux--id-window "enkan-repl-terminal" (id)) (declare-function enkan-repl--terminal-tmux-kill-workspace "enkan-repl-terminal" (workspace-id)) (declare-function enkan-repl-tmux-refresh-workspace "enkan-repl-terminal" (&optional quiet)) @@ -558,20 +559,32 @@ manually edited or may legitimately end in numeric suffixes such as foo-2." "Build a minimal workspace plist from live tmux SESSION. This uses pane cwd basenames for project/session names when available. tmux window names remain the aliases used to address live tmux windows." - (let ((window-cwds (if (fboundp 'enkan-repl--terminal-tmux--list-window-cwds) - (enkan-repl--terminal-tmux--list-window-cwds session) - (mapcar (lambda (window) (cons window nil)) - (enkan-repl--terminal-tmux--list-windows - session)))) - (counter 0) - session-list - aliases - target-directories - current-project) - (dolist (window-cwd window-cwds) - (let* ((window (car window-cwd)) - (cwd (cdr window-cwd)) - (id (enkan-repl--terminal-tmux--make-id session window)) + (let* ((window-info + (and (fboundp 'enkan-repl--terminal-tmux--list-window-info) + (enkan-repl--terminal-tmux--list-window-info session))) + (window-infos (cond + (window-info window-info) + ((fboundp 'enkan-repl--terminal-tmux--list-window-cwds) + (mapcar (lambda (window-cwd) + (list :window (car window-cwd) + :cwd (cdr window-cwd))) + (enkan-repl--terminal-tmux--list-window-cwds + session))) + (t + (mapcar (lambda (window) + (list :window window)) + (enkan-repl--terminal-tmux--list-windows + session))))) + (counter 0) + session-list + aliases + target-directories + current-project) + (dolist (info window-infos) + (let* ((window (plist-get info :window)) + (cwd (plist-get info :cwd)) + (pane (plist-get info :pane)) + (id (enkan-repl--terminal-tmux--make-id session window pane)) (project (enkan-repl--tmux-reattach-project-name window cwd)) (alias (if (and (stringp window) (not (string-empty-p window))) window diff --git a/test/enkan-repl-core-test.el b/test/enkan-repl-core-test.el index 6f45605..6de65f9 100644 --- a/test/enkan-repl-core-test.el +++ b/test/enkan-repl-core-test.el @@ -75,13 +75,13 @@ "Manual tmux reattach restores persisted state for live tmux sessions." (let ((saved-workspaces '(("01" :current-project "old" - :session-list ((1 . "old")) - :session-counter 1 - :project-aliases nil) + :session-list ((1 . "old")) + :session-counter 1 + :project-aliases nil) ("02" :current-project "proj" - :session-list ((1 . "proj")) - :session-counter 1 - :project-aliases (("p" . "proj"))))) + :session-list ((1 . "proj")) + :session-counter 1 + :project-aliases (("p" . "proj"))))) (enkan-repl-terminal-backend 'tmux) (enkan-repl--workspaces nil) (enkan-repl--current-workspace "01") @@ -102,6 +102,8 @@ ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) (lambda (_session) '(("window" . "/tmp/window")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () (list (format "enkan-%s:window" @@ -147,6 +149,8 @@ (lambda (_session) '(("enkan-repl" . "/repo/enkan-repl") ("worker-2" . "/repo/worker")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () (list "enkan-03:enkan-repl" "enkan-03:worker-2"))) @@ -205,6 +209,8 @@ (lambda (_session) '(("proj" . "/repo/proj-a") ("proj-2" . "/repo/proj-b")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () '("enkan-04:proj" "enkan-04:proj-2"))) @@ -242,6 +248,8 @@ ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) (lambda (_session) '(("foo-2" . "/repo/foo-2")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () '("enkan-06:foo-2"))) @@ -280,6 +288,8 @@ ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) (lambda (_session) '(("enkan-repl" . "/repo/enkan-repl")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () '("enkan-03:enkan-repl"))) @@ -320,9 +330,9 @@ "Manual tmux reattach should add live cwd paths to persisted workspaces." (let ((saved-workspaces '(("05" :current-project "er" - :session-list ((1 . "er")) - :session-counter 1 - :project-aliases (("er" . "er"))))) + :session-list ((1 . "er")) + :session-counter 1 + :project-aliases (("er" . "er"))))) (enkan-repl-terminal-backend 'tmux) (enkan-repl--workspaces nil) (enkan-repl--current-workspace nil) @@ -339,6 +349,8 @@ ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) (lambda (_session) '(("er" . "/Users/me/dev/enkan-repl")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () nil)) ((symbol-function 'enkan-repl-state-save) @@ -356,17 +368,17 @@ "Reattach should keep cwd addressable when tmux window names differ." (let ((saved-workspaces '(("02" :current-project "lat" - :session-list ((1 . "lat")) - :session-counter 0 - :project-aliases (("lat" . "lat")) - :target-directories - (("lattice-system" . ("lattice-system" . "/old/lat")))) + :session-list ((1 . "lat")) + :session-counter 0 + :project-aliases (("lat" . "lat")) + :target-directories + (("lattice-system" . ("lattice-system" . "/old/lat")))) ("05" :current-project "er" - :session-list ((1 . "enkan-repl")) - :session-counter 0 - :project-aliases (("er" . "enkan-repl")) - :target-directories - (("enkan-repl" . ("enkan-repl" . "/old/er")))))) + :session-list ((1 . "enkan-repl")) + :session-counter 0 + :project-aliases (("er" . "enkan-repl")) + :target-directories + (("enkan-repl" . ("enkan-repl" . "/old/er")))))) (enkan-repl-terminal-backend 'tmux) (enkan-repl--workspaces nil) (enkan-repl--current-workspace nil) @@ -387,6 +399,8 @@ '(("lattice-system" . "/Users/me/dev/lattice-system"))) ((string= session "enkan-05") '(("enkan-repl" . "/Users/me/dev/enkan-repl")))))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () nil)) ((symbol-function 'enkan-repl-state-save) @@ -408,19 +422,19 @@ "Manual tmux reattach recreates mirrors even when state is already current." (let* ((saved-workspaces '(("01" :current-project "old" - :session-list ((1 . "old")) - :session-counter 1 - :project-aliases nil - :target-directories - (("old" . ("old" . "/tmp/window")) - ("window" . ("window" . "/tmp/window")))) + :session-list ((1 . "old")) + :session-counter 1 + :project-aliases nil + :target-directories + (("old" . ("old" . "/tmp/window")) + ("window" . ("window" . "/tmp/window")))) ("02" :current-project "proj" - :session-list ((1 . "proj")) - :session-counter 1 - :project-aliases nil - :target-directories - (("proj" . ("proj" . "/tmp/window")) - ("window" . ("window" . "/tmp/window")))))) + :session-list ((1 . "proj")) + :session-counter 1 + :project-aliases nil + :target-directories + (("proj" . ("proj" . "/tmp/window")) + ("window" . ("window" . "/tmp/window")))))) (enkan-repl-terminal-backend 'tmux) (enkan-repl--workspaces saved-workspaces) (enkan-repl--current-workspace "02") @@ -440,6 +454,8 @@ ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) (lambda (_session) '(("window" . "/tmp/window")))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-info) + (lambda (_session) nil)) ((symbol-function 'enkan-repl--terminal-list) (lambda () (list (format "enkan-%s:window" @@ -511,7 +527,7 @@ (ert-deftest test-enkan-repl--find-directory-by-project-name () "Test finding directory by project name." (let ((enkan-repl-projects '("/home/user/my-project" - "/home/user/another-project")) + "/home/user/another-project")) (enkan-repl-target-directories '("/opt/special-project"))) ;; The function checks target-directories first (let ((result (enkan-repl--find-directory-by-project-name "special-project"))) diff --git a/test/enkan-repl-terminal-test.el b/test/enkan-repl-terminal-test.el index 9fb0613..e1c612f 100644 --- a/test/enkan-repl-terminal-test.el +++ b/test/enkan-repl-terminal-test.el @@ -28,7 +28,10 @@ documented opt-in alternative; see README." (should (string= "enkan-01:lat" (enkan-repl--terminal-tmux--make-id "enkan-01" "lat"))) (should (string= "enkan-99:my-proj-2" - (enkan-repl--terminal-tmux--make-id "enkan-99" "my-proj-2")))) + (enkan-repl--terminal-tmux--make-id "enkan-99" "my-proj-2"))) + (should (string= "enkan-01:dr-remote.jp|%12" + (enkan-repl--terminal-tmux--make-id + "enkan-01" "dr-remote.jp" "%12")))) (ert-deftest test-enkan-repl--terminal-tmux--id-session () (should (string= "enkan-01" @@ -47,9 +50,21 @@ documented opt-in alternative; see README." ;; from the first colon onward. (should (string= "lat.0" (enkan-repl--terminal-tmux--id-window "enkan-01:lat.0"))) + (should (string= "dr-remote.jp" + (enkan-repl--terminal-tmux--id-window + "enkan-01:dr-remote.jp|%12"))) (should (null (enkan-repl--terminal-tmux--id-window "no-colon"))) (should (null (enkan-repl--terminal-tmux--id-window nil)))) +(ert-deftest test-enkan-repl--terminal-tmux--target-prefers-pane-id () + "Tmux commands should use stable pane ids when present." + (should (string= "%12" + (enkan-repl--terminal-tmux--target + "enkan-01:dr-remote.jp|%12"))) + (should (string= "enkan-01:lat" + (enkan-repl--terminal-tmux--target + "enkan-01:lat")))) + (ert-deftest test-enkan-repl--terminal-tmux--id-workspace () "Workspace id is parsed from tmux target session names." (let ((enkan-repl-tmux-session-prefix "enkan-")) @@ -394,7 +409,7 @@ documented opt-in alternative; see README." (buf nil) cwd-lookup-started) (unwind-protect - (cl-letf (((symbol-function 'enkan-repl--terminal-tmux--pane-cwd-async) + (cl-letf (((symbol-function 'enkan-repl--terminal-tmux--pane-cwd-async) (lambda (_id _callback) (setq cwd-lookup-started t) nil))) @@ -709,7 +724,7 @@ documented opt-in alternative; see README." (enkan-repl--terminal-tmux--mirror-refresh buf) (should (= 0 capture-count)) (with-current-buffer buf - (should (eq enkan-repl--tmux-mirror-state 'hidden))))) + (should (eq enkan-repl--tmux-mirror-state 'hidden))))) (kill-buffer buf)))) (ert-deftest test-enkan-repl--terminal-tmux--mirror-refresh-skips-minibuffer () From 45f606ddfae9b77f94e813a8406c465638d82a75 Mon Sep 17 00:00:00 2001 From: phasetr Date: Sat, 16 May 2026 21:19:53 +0900 Subject: [PATCH 3/6] test: cover dotted tmux window pane targets --- test/enkan-repl-terminal-test.el | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/enkan-repl-terminal-test.el b/test/enkan-repl-terminal-test.el index e1c612f..bfed53c 100644 --- a/test/enkan-repl-terminal-test.el +++ b/test/enkan-repl-terminal-test.el @@ -65,6 +65,46 @@ documented opt-in alternative; see README." (enkan-repl--terminal-tmux--target "enkan-01:lat")))) +(ert-deftest test-enkan-repl--terminal-tmux-start-dotted-window-uses-pane-id () + "Starting dr-remote.jp should return an id that commands target by pane id." + (let ((enkan-repl--current-workspace "01") + (enkan-repl-tmux-session-prefix "enkan-") + calls) + (cl-letf (((symbol-function 'enkan-repl--terminal-tmux--ensure-bell-monitor) + (lambda () nil)) + ((symbol-function 'enkan-repl--terminal-tmux--has-session) + (lambda (_session) nil)) + ((symbol-function 'enkan-repl--terminal-tmux--call) + (lambda (args &optional capture) + (push (list args capture) calls) + (when (member "new-session" args) + "%12")))) + (let ((id (enkan-repl--terminal-tmux-start "/repo/dr-remote.jp/"))) + (should (string= "enkan-01:dr-remote.jp|%12" id)) + (should (string= "%12" (enkan-repl--terminal-tmux--target id))) + (should (equal '(("new-session" "-d" "-P" "-F" "#{pane_id}" + "-s" "enkan-01" "-c" "/repo/dr-remote.jp/" + "-n" "dr-remote.jp") + t) + (car calls))))))) + +(ert-deftest test-enkan-repl--terminal-tmux--capture-pane-async-targets-pane-id () + "Mirror capture for dr-remote.jp should pass %pane_id to tmux -t." + (let ((enkan-repl-tmux-executable "tmux") + (enkan-repl-tmux-mirror-capture-timeout nil) + command) + (cl-letf (((symbol-function 'executable-find) + (lambda (_executable) t)) + ((symbol-function 'make-process) + (lambda (&rest plist) + (setq command (plist-get plist :command)) + 'mock-process))) + (enkan-repl--terminal-tmux--capture-pane-async + "enkan-01:dr-remote.jp|%12" 40 (lambda (&rest _) nil)) + (should (equal '("tmux" "capture-pane" "-p" "-J" "-S" "-40" + "-t" "%12") + command))))) + (ert-deftest test-enkan-repl--terminal-tmux--id-workspace () "Workspace id is parsed from tmux target session names." (let ((enkan-repl-tmux-session-prefix "enkan-")) From 4596702110b50cef828d7f07518d6f2c3bb98c39 Mon Sep 17 00:00:00 2001 From: phasetr Date: Sat, 16 May 2026 21:28:07 +0900 Subject: [PATCH 4/6] feat: auto-apply workspace layouts --- enkan-repl.el | 26 ++++++++++- test/enkan-repl-workspace-create-test.el | 55 ++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/enkan-repl.el b/enkan-repl.el index 01fd66e..5895a18 100644 --- a/enkan-repl.el +++ b/enkan-repl.el @@ -155,6 +155,10 @@ ;; Declare external functions from hmenu to silence byte-compiler when not loaded (declare-function hmenu "hmenu" (prompt choices)) +;; Optional example layout command. It is loaded by examples/keybinding.el. +(declare-function enkan-repl-setup-current-project-layout + "examples/window-layouts" ()) + ;; Declare external functions to avoid byte-compiler warnings (declare-function eat "eat" (&optional program)) (declare-function eat--send-string "eat" (process string)) @@ -1579,6 +1583,21 @@ Implemented as pure function, side effects are handled by upper functions." (cons project-name project-path)) (error "Project alias '%s' not found in registry" alias)))) +(defun enkan-repl--maybe-setup-current-project-layout (&optional context) + "Run the optional current project layout command. +CONTEXT is included in error messages to identify the caller. The layout +command lives in examples/window-layouts.el, so core setup only calls it when +it has been loaded by user configuration." + (when (and enkan-repl--current-workspace + (enkan-repl--ws-current-project) + (fboundp 'enkan-repl-setup-current-project-layout)) + (condition-case err + (enkan-repl-setup-current-project-layout) + (error + (message "Failed to set up current project layout%s: %s" + (if context (format " after %s" context) "") + (error-message-string err)))))) + ;;;###autoload (defun enkan-repl-setup () "Set up window layout based on context. @@ -1614,7 +1633,8 @@ Category: Session Controller" (enkan-repl--setup-create-workspace-with-project nil project-name) (let ((old-state (list (enkan-repl--ws-current-project) (copy-tree (enkan-repl--ws-session-list)) - (enkan-repl--ws-session-counter)))) + (enkan-repl--ws-session-counter))) + (setup-succeeded nil)) (with-output-to-temp-buffer buffer-name (princ (format "=== ENKAN-REPL AUTO SETUP: %s ===\n\n" project-name)) (condition-case err @@ -1642,11 +1662,14 @@ Category: Session Controller" (length alias-list))))) ;; Set final project configuration (enkan-repl--ws-set-current-project project-name) + (setq setup-succeeded t) (princ (format "\n✅ Setup completed for project: %s\n" project-name)) (princ "Arrange your preferred window configuration!\n\n") (princ "=== END SETUP ===\n")) (error (princ (format "\n❌ Setup failed: %s\n" (error-message-string err)))))) + (when setup-succeeded + (enkan-repl--maybe-setup-current-project-layout "setup")) (message "Center file setup complete with workspace %s" enkan-repl--current-workspace))) (message "Center file not configured or no projects defined"))))) @@ -2493,6 +2516,7 @@ Uses `hmenu' if available to show workspace ID with its project." (enkan-repl--save-workspace-state) (setq enkan-repl--current-workspace target-id) (enkan-repl--load-workspace-state target-id) + (enkan-repl--maybe-setup-current-project-layout "workspace switch") (message "Switched to workspace %s" target-id)))))) ;;;###autoload diff --git a/test/enkan-repl-workspace-create-test.el b/test/enkan-repl-workspace-create-test.el index cb79aa0..4d24639 100644 --- a/test/enkan-repl-workspace-create-test.el +++ b/test/enkan-repl-workspace-create-test.el @@ -92,6 +92,7 @@ (enkan-repl-center-file "hmenu") (enkan-repl-projects '(("MyProject" "alias1"))) (buffer-file-name-result nil) + (layout-called nil) ((symbol-function 'buffer-file-name) (lambda () buffer-file-name-result)) ((symbol-function 'enkan-repl--is-standard-file-path) (lambda (file dir) nil)) ((symbol-function 'enkan-repl--is-center-file-path) (lambda (file projects) t)) @@ -103,8 +104,14 @@ ((symbol-function 'enkan-repl--setup-enable-global-mode) (lambda (buf) nil)) ((symbol-function 'enkan-repl--setup-reset-config) (lambda (buf) nil)) ((symbol-function 'enkan-repl--setup-set-project-aliases) (lambda (name aliases buf) nil)) - ((symbol-function 'enkan-repl--setup-start-sessions) (lambda (aliases buf) nil)) - ((symbol-function 'enkan-repl--ws-current-project) (lambda () nil)) + ((symbol-function 'enkan-repl--setup-start-sessions) + (lambda (aliases buf) + (list :success-count 1 :failure-count 0))) + ((symbol-function 'enkan-repl-setup-current-project-layout) + (lambda () + (setq layout-called + (list enkan-repl--current-workspace + enkan-repl--current-project)))) ((symbol-function 'enkan-repl--ws-session-list) (lambda () nil)) ((symbol-function 'enkan-repl--ws-session-counter) (lambda () 0)) ((symbol-function 'message) (lambda (&rest args) nil))) @@ -116,7 +123,49 @@ ;; Verify project name and aliases (let ((ws-state (cdr (assoc "01" enkan-repl--workspaces)))) (should (string= (plist-get ws-state :current-project) "MyProject")) - (should (equal (plist-get ws-state :project-aliases) '("alias1")))))) + (should (equal (plist-get ws-state :project-aliases) '("alias1")))) + (should (equal layout-called '("01" "MyProject"))))) + +(ert-deftest test-enkan-repl-workspace-switch-runs-current-project-layout () + "Workspace switch should apply the current project's layout after loading." + (let ((enkan-repl--workspaces nil) + (enkan-repl--current-workspace nil) + (enkan-repl-session-list nil) + (enkan-repl--session-counter 0) + (enkan-repl--current-project nil) + (enkan-repl-project-aliases nil) + (layout-called nil)) + (setq enkan-repl--workspaces + (enkan-repl--add-workspace enkan-repl--workspaces "01")) + (setq enkan-repl--current-workspace "01") + (setq enkan-repl-session-list '((1 . "project-a"))) + (setq enkan-repl--session-counter 1) + (setq enkan-repl--current-project "project-a") + (enkan-repl--save-workspace-state "01") + (setq enkan-repl--workspaces + (enkan-repl--add-workspace enkan-repl--workspaces "02")) + (setq enkan-repl--current-workspace "02") + (setq enkan-repl-session-list '((1 . "project-b") (2 . "project-b"))) + (setq enkan-repl--session-counter 2) + (setq enkan-repl--current-project "project-b") + (enkan-repl--save-workspace-state "02") + (setq enkan-repl--current-workspace "01") + (enkan-repl--load-workspace-state "01") + (cl-letf (((symbol-function 'hmenu) + (lambda (prompt choices) + (car choices))) + ((symbol-function 'enkan-repl-setup-current-project-layout) + (lambda () + (setq layout-called + (list enkan-repl--current-workspace + enkan-repl--current-project + enkan-repl-session-list)))) + ((symbol-function 'message) (lambda (&rest args) nil))) + (enkan-repl-workspace-switch) + (should (equal enkan-repl--current-workspace "02")) + (should (equal layout-called + '("02" "project-b" + ((1 . "project-b") (2 . "project-b")))))))) (ert-deftest test-enkan-repl--setup-start-sessions-reports-failures () "Session setup should report failures instead of looking successful." From 65c50808cc13508637cd7492cd097ef1791c4f96 Mon Sep 17 00:00:00 2001 From: phasetr Date: Sat, 16 May 2026 21:34:33 +0900 Subject: [PATCH 5/6] test: cover automatic layout switch paths --- enkan-repl-workspace-list.el | 3 ++ enkan-repl.el | 5 ++- test/enkan-repl-workspace-create-test.el | 9 ++++- ...kan-repl-workspace-delete-deletion-test.el | 31 ++++++++++++++++ test/enkan-repl-workspace-list-test.el | 35 +++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/enkan-repl-workspace-list.el b/enkan-repl-workspace-list.el index cec9c17..fb29fb9 100644 --- a/enkan-repl-workspace-list.el +++ b/enkan-repl-workspace-list.el @@ -23,6 +23,8 @@ (declare-function enkan-repl--get-workspace-state "enkan-repl-workspace" (workspaces workspace-id)) (declare-function enkan-repl--save-workspace-state "enkan-repl" ()) (declare-function enkan-repl--load-workspace-state "enkan-repl" (workspace-id)) +(declare-function enkan-repl--maybe-setup-current-project-layout "enkan-repl" + (&optional context)) (declare-function enkan-repl--get-project-paths-for-current "enkan-repl" (current-project projects target-directories)) (declare-function enkan-repl--projects-with-current-aliases "enkan-repl" (projects current-project project-aliases)) (declare-function enkan-repl--get-project-info-from-directories "enkan-repl-utils" (alias target-directories)) @@ -133,6 +135,7 @@ TARGET-DIRECTORIES is the list of target directories." (setq enkan-repl--current-workspace workspace-id) ;; Load the new workspace state (enkan-repl--load-workspace-state workspace-id) + (enkan-repl--maybe-setup-current-project-layout "workspace list switch") (message "Switched to workspace %s" workspace-id)))))) (defun enkan-repl-workspace-list-refresh () diff --git a/enkan-repl.el b/enkan-repl.el index 5895a18..58bc26a 100644 --- a/enkan-repl.el +++ b/enkan-repl.el @@ -1624,6 +1624,7 @@ Category: Session Controller" ;; Move back to left window and open project input file (other-window -1) (enkan-repl-open-project-input-file) + (enkan-repl--maybe-setup-current-project-layout "setup") (message "Basic window layout setup complete with workspace %s" enkan-repl--current-workspace)) ;; Center file mode: check if center file is specified as non-empty string (if (enkan-repl--is-center-file-path enkan-repl-center-file enkan-repl-projects) @@ -2055,7 +2056,9 @@ new default workspace is created via `enkan-repl-setup'." (cond (remaining-workspaces (setq enkan-repl--current-workspace (car remaining-workspaces)) - (enkan-repl--load-workspace-state enkan-repl--current-workspace)) + (enkan-repl--load-workspace-state enkan-repl--current-workspace) + (enkan-repl--maybe-setup-current-project-layout + "workspace delete")) (t (enkan-repl-setup))))) (list :deleted t diff --git a/test/enkan-repl-workspace-create-test.el b/test/enkan-repl-workspace-create-test.el index 4d24639..636a9d2 100644 --- a/test/enkan-repl-workspace-create-test.el +++ b/test/enkan-repl-workspace-create-test.el @@ -64,6 +64,7 @@ (enkan-repl--current-workspace nil) (buffer-file-name-result "/path/to/file.input.txt") (default-directory "/path/to/project/") + (layout-called nil) ((symbol-function 'buffer-file-name) (lambda () buffer-file-name-result)) ((symbol-function 'enkan-repl--is-standard-file-path) (lambda (file dir) t)) ((symbol-function 'enkan-repl--initialize-default-workspace) (lambda ())) @@ -72,6 +73,11 @@ ((symbol-function 'other-window) (lambda (n))) ((symbol-function 'enkan-repl-start-session) (lambda (&optional force))) ((symbol-function 'enkan-repl-open-project-input-file) (lambda ())) + ((symbol-function 'enkan-repl-setup-current-project-layout) + (lambda () + (setq layout-called + (list enkan-repl--current-workspace + enkan-repl--current-project)))) ((symbol-function 'message) (lambda (&rest args) nil))) ;; Execute enkan-repl-setup (enkan-repl-setup) @@ -80,7 +86,8 @@ (should (assoc "01" enkan-repl--workspaces)) ;; Verify project name from directory (let ((ws-state (cdr (assoc "01" enkan-repl--workspaces)))) - (should (string= (plist-get ws-state :current-project) "project"))))) + (should (string= (plist-get ws-state :current-project) "project"))) + (should (equal layout-called '("01" "project"))))) (ert-deftest test-enkan-repl-setup-creates-workspace-center-file () "Test that enkan-repl-setup creates workspace for center file." diff --git a/test/enkan-repl-workspace-delete-deletion-test.el b/test/enkan-repl-workspace-delete-deletion-test.el index 7ea6956..851abb0 100644 --- a/test/enkan-repl-workspace-delete-deletion-test.el +++ b/test/enkan-repl-workspace-delete-deletion-test.el @@ -44,6 +44,37 @@ (should (equal 1 enkan-repl--session-counter)) (should (equal "other" enkan-repl--current-project))))) +(ert-deftest test-workspace-delete-current-runs-current-project-layout () + "Deleting the current workspace should apply layout for the loaded remainder." + (let ((enkan-repl--workspaces + '(("01" . (:current-project "test" + :session-list ((1 . "test")) + :session-counter 1 + :project-aliases nil)) + ("02" . (:current-project "other" + :session-list ((1 . "other") (2 . "other")) + :session-counter 2 + :project-aliases nil)))) + (enkan-repl--current-workspace "01") + (enkan-repl-session-list '((1 . "test"))) + (enkan-repl--session-counter 1) + (enkan-repl--current-project "test") + (enkan-repl-project-aliases nil) + (layout-called nil)) + (cl-letf (((symbol-function 'enkan-repl--stop-workspace-terminals) + (lambda (_workspace-id) + (list :buffers-killed 0 :tmux-killed nil))) + ((symbol-function 'enkan-repl-setup-current-project-layout) + (lambda () + (setq layout-called + (list enkan-repl--current-workspace + enkan-repl--current-project + enkan-repl-session-list))))) + (enkan-repl--delete-workspace-completely "01") + (should (equal layout-called + '("02" "other" + ((1 . "other") (2 . "other")))))))) + (ert-deftest test-workspace-delete-preserves-target-registry-for-new-setup () "Deleting a workspace must not make unrelated project aliases unusable." (let ((enkan-repl--workspaces diff --git a/test/enkan-repl-workspace-list-test.el b/test/enkan-repl-workspace-list-test.el index 3831d07..ac0f5a3 100644 --- a/test/enkan-repl-workspace-list-test.el +++ b/test/enkan-repl-workspace-list-test.el @@ -6,6 +6,7 @@ ;;; Code: (require 'ert) +(require 'cl-lib) (require 'enkan-repl) (require 'enkan-repl-workspace-list) @@ -137,6 +138,40 @@ ;; Clean up (kill-buffer "*Enkan Workspace List*"))) +(ert-deftest test-workspace-list-switch-runs-current-project-layout () + "Switching from the workspace list should apply the loaded workspace layout." + (let ((enkan-repl--workspaces + '(("01" . (:current-project "project-a" + :project-aliases nil + :session-list ((1 . "project-a")) + :session-counter 1)) + ("02" . (:current-project "project-b" + :project-aliases nil + :session-list ((1 . "project-b") (2 . "project-b")) + :session-counter 2)))) + (enkan-repl--current-workspace "01") + (enkan-repl--current-project "project-a") + (enkan-repl-session-list '((1 . "project-a"))) + (enkan-repl--session-counter 1) + (enkan-repl-project-aliases nil) + (layout-called nil)) + (with-temp-buffer + (insert (propertize "02 [project-b]" 'workspace-id "02")) + (goto-char (point-min)) + (cl-letf (((symbol-function 'quit-window) (lambda (&rest _args) nil)) + ((symbol-function 'enkan-repl-setup-current-project-layout) + (lambda () + (setq layout-called + (list enkan-repl--current-workspace + enkan-repl--current-project + enkan-repl-session-list)))) + ((symbol-function 'message) (lambda (&rest _args) nil))) + (enkan-repl-workspace-list-switch-to-workspace) + (should (equal enkan-repl--current-workspace "02")) + (should (equal layout-called + '("02" "project-b" + ((1 . "project-b") (2 . "project-b"))))))))) + (ert-deftest test-workspace-list-empty () "Test workspace list when no workspaces exist." (let ((enkan-repl--workspaces nil)) From 3826018760388c8c8c72407cc7621c0644422fc6 Mon Sep 17 00:00:00 2001 From: Yoshitsugu Sekine Date: Sat, 16 May 2026 21:48:37 +0900 Subject: [PATCH 6/6] fix: resolve layout session aliases --- examples/window-layouts.el | 26 ++++++++++++++++--- ...enkan-repl-workspace-switch-layout-test.el | 24 +++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/examples/window-layouts.el b/examples/window-layouts.el index c22c71c..773fd57 100644 --- a/examples/window-layouts.el +++ b/examples/window-layouts.el @@ -33,6 +33,18 @@ PROJECT-REGISTRY format: ((alias . (project-name . project-path)) ...)" (project-path (when project-info (cdr (cdr project-info))))) project-path)) +(defun enkan-repl--layout-session-project-info (session-project project-registry) + "Return project info for SESSION-PROJECT from PROJECT-REGISTRY. +SESSION-PROJECT may be either a real project name or a configured alias." + (when session-project + (or (cdr (assoc session-project project-registry)) + (cdr + (cl-find-if (lambda (entry) + (let ((value (cdr entry))) + (and (consp value) + (string= (car value) session-project)))) + project-registry))))) + (defun enkan-repl--setup-window-terminal-buffer-pure (window session-number session-list project-registry) "Pure function to determine terminal buffer setup for WINDOW and SESSION-NUMBER. Returns cons (window . buffer-name) or nil if session not registered. @@ -43,7 +55,10 @@ suffix." (project-name (enkan-repl--session-entry-project entry-value)) (instance (enkan-repl--session-entry-instance entry-value))) (when project-name - (let ((project-path (enkan-repl--get-project-path-from-directories project-name project-registry))) + (let* ((project-info + (enkan-repl--layout-session-project-info + project-name project-registry)) + (project-path (cdr project-info))) (when project-path (let* ((expanded-path (expand-file-name project-path)) ;; Ensure path ends with / for consistency with terminal buffer names. @@ -73,6 +88,10 @@ not returned." (entry-value (cdr entry)) (project-name (enkan-repl--session-entry-project entry-value)) (instance (enkan-repl--session-entry-instance entry-value)) + (project-info + (enkan-repl--layout-session-project-info + project-name enkan-repl-target-directories)) + (resolved-project-name (or (car project-info) project-name)) (setup (enkan-repl--setup-window-terminal-buffer-pure nil session-number (enkan-repl--ws-session-list) enkan-repl-target-directories)) @@ -88,8 +107,9 @@ not returned." (and name (enkan-repl--buffer-name-matches-workspace name enkan-repl--current-workspace) - (string= (or (enkan-repl--extract-project-name name) "") - project-name) + (member (or (enkan-repl--extract-project-name name) "") + (delete-dups + (list project-name resolved-project-name))) (= (enkan-repl--buffer-name->instance name) instance) (enkan-repl--buffer-alive-as-terminal-p buffer)))) (buffer-list)))))) diff --git a/test/enkan-repl-workspace-switch-layout-test.el b/test/enkan-repl-workspace-switch-layout-test.el index 1a9095e..d8a1e0e 100644 --- a/test/enkan-repl-workspace-switch-layout-test.el +++ b/test/enkan-repl-workspace-switch-layout-test.el @@ -177,6 +177,30 @@ (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(ert-deftest test-workspace-layout-resolves-session-alias-to-project-name () + "C-M-l should resolve legacy alias session entries to project buffers." + (let* ((project-dir "/Users/sekine/dev/self/enkan-repl/") + (good (generate-new-buffer + (format "*ws:01 enkan:%s*" project-dir))) + (enkan-repl--current-workspace "01") + (enkan-repl--current-project "er") + (enkan-repl-session-list '((1 . "er"))) + (enkan-repl-target-directories + `(("er" . ("enkan-repl" . ,project-dir)))) + (called nil)) + (unwind-protect + (progn + (with-current-buffer good + (setq-local enkan-repl--tmux-mirror-id "enkan-01:enkan-repl")) + (should (equal (list good) + (enkan-repl--registered-session-buffers))) + (cl-letf (((symbol-function 'enkan-repl-setup-1session-layout) + (lambda () (setq called 'one)))) + (enkan-repl-setup-current-project-layout) + (should (eq called 'one)))) + (when (buffer-live-p good) + (kill-buffer good))))) + (ert-deftest test-workspace-layout-deduplicates-session-list-buffer () "C-M-l should not count duplicate session entries resolving to one buffer." (let* ((project-dir "/Users/sekine/dev/self/enkan-repl/")