diff --git a/enkan-repl-utils.el b/enkan-repl-utils.el index cca1682..9e98adc 100644 --- a/enkan-repl-utils.el +++ b/enkan-repl-utils.el @@ -27,6 +27,10 @@ (declare-function enkan-repl--terminal-tmux-alive-p "enkan-repl-terminal" (id)) (declare-function enkan-repl--terminal-tmux-mirror-buffer-alive-p "enkan-repl-terminal" (buffer)) +(declare-function enkan-repl--terminal-tmux--id-workspace + "enkan-repl-terminal" (id)) +(declare-function enkan-repl--terminal-tmux--id-window + "enkan-repl-terminal" (id)) (defvar enkan-repl--tmux-mirror-id) ;;;; Buffer Name API (New - Compatibility Mode) @@ -89,6 +93,33 @@ Returns t if the buffer name has the correct workspace prefix." (stringp workspace-id) (string-match-p (format "^\\*ws:%s enkan:" workspace-id) name))) +(defun enkan-repl--buffer-tmux-id (buffer) + "Return BUFFER's tmux mirror id, or nil." + (when (and (bufferp buffer) + (buffer-live-p buffer) + (buffer-local-boundp 'enkan-repl--tmux-mirror-id buffer)) + (buffer-local-value 'enkan-repl--tmux-mirror-id buffer))) + +(defun enkan-repl--buffer-workspace-id (buffer) + "Return BUFFER's workspace id from tmux metadata or buffer name." + (let* ((id (enkan-repl--buffer-tmux-id buffer)) + (tmux-workspace + (and id + (fboundp 'enkan-repl--terminal-tmux--id-workspace) + (enkan-repl--terminal-tmux--id-workspace id)))) + (or tmux-workspace + (and (bufferp buffer) + (buffer-name buffer) + (enkan-repl--extract-workspace-id (buffer-name buffer)))))) + +(defun enkan-repl--buffer-matches-workspace-p (buffer workspace-id) + "Return non-nil when BUFFER belongs to WORKSPACE-ID. +Tmux mirror metadata wins over the buffer name so fallback buffers such as +`*tmux enkan-02:lat|%1*' are handled before cwd-based renaming finishes." + (and (stringp workspace-id) + (string= (or (enkan-repl--buffer-workspace-id buffer) "") + workspace-id))) + (defun enkan-repl--extract-workspace-id (name) "Extract workspace ID from buffer NAME. Returns workspace ID string (e.g., \"01\") or nil if not an enkan buffer." @@ -134,9 +165,8 @@ process-attached buffers and tmux mirror buffers)." (let ((count 0)) (dolist (buffer buffer-list) (when (and (bufferp buffer) - (buffer-name buffer) - (enkan-repl--buffer-name-matches-workspace - (buffer-name buffer) workspace-id) + (enkan-repl--buffer-matches-workspace-p + buffer workspace-id) (enkan-repl--buffer-alive-as-terminal-p buffer)) (setq count (1+ count)))) count)) @@ -185,7 +215,7 @@ state files while allowing multi-instance bookkeeping." "Extract final directory name from buffer name or path for use as project name. Example: \\='*ws:01 enkan:/path/to/pt-tools/*\\=' -> \\='pt-tools\\='" (let ((path (or (enkan-repl--buffer-name->path buffer-name-or-path) - buffer-name-or-path))) + buffer-name-or-path))) (file-name-nondirectory (directory-file-name path)))) (defun enkan-repl--encode-full-path (path prefix separator) @@ -233,6 +263,55 @@ nil, any instance for that directory matches." (eql instance (enkan-repl--buffer-name->instance buffer-name))))))) +(defun enkan-repl--target-window-name-matches-p (window names instance) + "Return non-nil when tmux WINDOW matches target NAMES and INSTANCE. +When INSTANCE is nil, any instance of the target is accepted." + (and (stringp window) + (let ((bases (delete-dups + (cl-remove-if-not #'stringp (copy-sequence names))))) + (if (and instance (integerp instance)) + (member window + (mapcar (lambda (base) + (if (> instance 1) + (format "%s-%d" base instance) + base)) + bases)) + (cl-some + (lambda (base) + (or (string= window base) + (string-match-p + (format "\\`%s-[0-9]+\\'" (regexp-quote base)) + window))) + bases))))) + +(defun enkan-repl--buffer-matches-target-p + (buffer workspace-id target-path &optional instance target-names) + "Return non-nil when BUFFER matches target identity. +WORKSPACE-ID scopes the match. TARGET-PATH is the registered project +directory. INSTANCE, when non-nil, requires a specific multi-instance index. +TARGET-NAMES are aliases or project names that may appear in tmux window ids. + +This function is the shared compatibility point between path-named enkan +buffers and tmux fallback mirror buffers." + (and (bufferp buffer) + (buffer-live-p buffer) + (stringp target-path) + (enkan-repl--buffer-matches-workspace-p buffer workspace-id) + (let ((name (buffer-name buffer)) + (path-base (enkan-repl--extract-project-name target-path))) + (or (and name + (enkan-repl--buffer-matches-directory + name target-path instance)) + (let* ((id (enkan-repl--buffer-tmux-id buffer)) + (window + (and id + (fboundp 'enkan-repl--terminal-tmux--id-window) + (enkan-repl--terminal-tmux--id-window id)))) + (enkan-repl--target-window-name-matches-p + window + (append target-names (list path-base)) + instance)))))) + (defun enkan-repl--extract-session-info (buffer-name buffer-live-p has-eat-process process-live-p) "Pure function to extract session info from buffer properties. BUFFER-NAME is the name of the buffer. @@ -500,8 +579,8 @@ FUNCTIONS should be a list of function info plists from Returns a string containing org-mode formatted function list." (let* ((functions (enkan-repl-utils--extract-function-info file-path)) (interactive-functions (cl-remove-if-not - (lambda (f) (plist-get f :interactive)) - functions))) + (lambda (f) (plist-get f :interactive)) + functions))) (enkan-repl-utils--generate-function-list-flat interactive-functions))) (defun enkan-repl-utils--extract-category-from-docstring (docstring) @@ -525,11 +604,11 @@ FUNCTIONS-LIST contains plists with :name, :docstring, :category." (let* ((docstring (plist-get func :docstring)) (category (enkan-repl-utils--extract-category-from-docstring docstring)) (clean-docstring (if category - (replace-regexp-in-string " Category: .*$" "" docstring) - docstring)) + (replace-regexp-in-string " Category: .*$" "" docstring) + docstring)) (func-info `(:name ,(plist-get func :name) - :docstring ,clean-docstring - :category ,(or category "Uncategorized")))) + :docstring ,clean-docstring + :category ,(or category "Uncategorized")))) ;; Add to appropriate category list (let ((category-entry (assoc (or category "Uncategorized") categorized-functions))) (if category-entry diff --git a/enkan-repl.el b/enkan-repl.el index 58bc26a..cc617eb 100644 --- a/enkan-repl.el +++ b/enkan-repl.el @@ -98,6 +98,14 @@ (declare-function enkan-repl-utils--encode-full-path "enkan-repl-utils" (path prefix separator)) (declare-function enkan-repl-utils--decode-full-path "enkan-repl-utils" (encoded-name prefix separator)) (declare-function enkan-repl--buffer-matches-directory "enkan-repl-utils" (buffer-name target-directory &optional instance)) +(declare-function enkan-repl--buffer-matches-target-p + "enkan-repl-utils" + (buffer workspace-id target-path &optional instance target-names)) +(declare-function enkan-repl--buffer-matches-workspace-p + "enkan-repl-utils" (buffer workspace-id)) +(declare-function enkan-repl--buffer-tmux-id "enkan-repl-utils" (buffer)) +(declare-function enkan-repl--target-window-name-matches-p + "enkan-repl-utils" (window names instance)) (declare-function enkan-repl--buffer-alive-as-terminal-p "enkan-repl-utils" (buffer)) (declare-function enkan-repl--terminal-tmux-alive-p "enkan-repl-terminal" (id)) (declare-function enkan-repl--terminal-tmux-mirror-buffer-alive-p "enkan-repl-terminal" (buffer)) @@ -659,7 +667,33 @@ cwd values. PERSISTED entries with aliases not present in LIVE are retained." alias project-aliases) (cdr (cdr entry))))) aliases live)) - (t live)))) + (t live)))) + +(defun enkan-repl--tmux-reattach-normalize-session-list (state live target-directories) + "Return session list for STATE reconciled with LIVE tmux data. +Persisted session lists win when present. When persisted state lost its +session list, rebuild it from LIVE using TARGET-DIRECTORIES after alias +normalization so project aliases such as lat -> lattice-system stay stable." + (or (plist-get state :session-list) + (let ((live-sessions (plist-get live :session-list))) + (cond + ((null live-sessions) nil) + ((= (length live-sessions) (length target-directories)) + (cl-mapcar + (lambda (session-entry target-entry) + (let* ((entry-value (cdr session-entry)) + (target-info (cdr target-entry)) + (project (if (consp target-info) + (car target-info) + (enkan-repl--session-entry-project + entry-value))) + (instance (enkan-repl--session-entry-instance + entry-value))) + (cons (car session-entry) + (enkan-repl--make-session-entry-value + project instance)))) + live-sessions target-directories)) + (t live-sessions))))) (defun enkan-repl--tmux-reattach-merge-state (persisted live) "Merge one PERSISTED workspace state with one LIVE tmux-derived state." @@ -671,10 +705,17 @@ cwd values. PERSISTED entries with aliases not present in LIVE are retained." (target-directories (enkan-repl--tmux-reattach-merge-target-directories (plist-get persisted :target-directories) - live-target-directories))) - (if target-directories - (plist-put state :target-directories target-directories) - state))) + live-target-directories)) + (session-list + (enkan-repl--tmux-reattach-normalize-session-list + state live live-target-directories))) + (when target-directories + (setq state (plist-put state :target-directories target-directories))) + (when (and (null (plist-get state :session-list)) + session-list) + (setq state (plist-put state :session-list session-list)) + (setq state (plist-put state :session-counter (length session-list)))) + state)) (defun enkan-repl--tmux-reattach-merge-workspaces (persisted live) "Merge PERSISTED and LIVE workspace alists. @@ -1192,22 +1233,17 @@ matching buffer regardless of instance. Only returns buffers that belong to the current workspace." (let ((target-dir (or directory default-directory)) - (current-ws enkan-repl--current-workspace) + (current-ws (or enkan-repl--current-workspace "01")) (matching-buffer nil)) (cl-block search-buffers (dolist (buf (buffer-list)) (let - ((name (buffer-name buf)) - (eat-mode - (with-current-buffer buf - (and (boundp 'eat-mode) eat-mode)))) + ((name (buffer-name buf))) (when (and (buffer-live-p buf) - name ; Ensure name is not nil - ;; Check workspace match - (enkan-repl--buffer-name-matches-workspace name current-ws) - ;; Check for directory-specific enkan buffer using the buffer-name matcher - (enkan-repl--buffer-matches-directory name target-dir instance)) + name + (enkan-repl--buffer-matches-target-p + buf current-ws target-dir instance nil)) (setq matching-buffer buf) (cl-return-from search-buffers))))) matching-buffer)) @@ -1583,6 +1619,71 @@ 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--restore-current-workspace-sessions-from-live-terminals () + "Restore an empty current workspace session list from live tmux windows. +This repairs stale persisted state where the workspace still has current project +and target directory metadata, but `enkan-repl-session-list' was saved as nil. +Returns non-nil when sessions were restored." + (condition-case nil + (when (and (eq enkan-repl-terminal-backend 'tmux) + enkan-repl--current-workspace + (enkan-repl--ws-current-project) + (null (enkan-repl--ws-session-list)) + (fboundp 'enkan-repl--terminal-list) + (fboundp 'enkan-repl--terminal-tmux--id-window)) + (let* ((current-project (enkan-repl--ws-current-project)) + (projects + (enkan-repl--projects-with-current-aliases + enkan-repl-projects + current-project + enkan-repl-project-aliases)) + (project-paths + (enkan-repl--get-project-paths-for-current + current-project projects enkan-repl-target-directories)) + (ids (enkan-repl--terminal-list)) + (session-number 0) + session-list) + (dolist (project-path project-paths) + (let* ((alias (car project-path)) + (path (cdr project-path)) + (project-info + (enkan-repl--get-project-info-from-directories + alias enkan-repl-target-directories)) + (target-project (or (and (consp project-info) + (car project-info)) + current-project)) + (instance (or (enkan-repl--target-alias-instance-for-path + alias path) + 1)) + (target-names + (delete-dups + (delq nil + (list alias current-project target-project + (enkan-repl--extract-project-name path))))) + (id + (cl-find-if + (lambda (candidate) + (enkan-repl--target-window-name-matches-p + (enkan-repl--terminal-tmux--id-window candidate) + target-names + instance)) + ids))) + (when id + (setq session-number (1+ session-number)) + (push (cons session-number + (enkan-repl--make-session-entry-value + target-project instance)) + session-list) + (when (fboundp 'enkan-repl--terminal-tmux--mirror-make) + (ignore-errors + (enkan-repl--terminal-tmux--mirror-make id t path)))))) + (when session-list + (enkan-repl--ws-set-session-list (nreverse session-list)) + (enkan-repl--ws-set-session-counter (length session-list)) + (enkan-repl--save-workspace-state) + t))) + (error nil))) + (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 @@ -1592,7 +1693,9 @@ it has been loaded by user configuration." (enkan-repl--ws-current-project) (fboundp 'enkan-repl-setup-current-project-layout)) (condition-case err - (enkan-repl-setup-current-project-layout) + (progn + (enkan-repl--restore-current-workspace-sessions-from-live-terminals) + (enkan-repl-setup-current-project-layout)) (error (message "Failed to set up current project layout%s: %s" (if context (format " after %s" context) "") @@ -1839,25 +1942,46 @@ Returns a plist with :status and other keys." (let* ((project-paths (when current-project (enkan-repl--get-project-paths-for-current current-project projects target-directories))) - ;; Helper function to convert path to buffer - (path-to-buffer (lambda (alias path) - (get-buffer - (enkan-repl--path->buffer-name - path - (enkan-repl--target-alias-instance-for-path - alias path))))) - ;; Helper function to check if buffer exists - (get-active-buffers (lambda (paths) - (cl-loop for (alias . path) in paths - for buffer = (funcall path-to-buffer alias path) - when buffer - collect (cons alias buffer))))) + (workspace-id (or enkan-repl--current-workspace "01")) + ;; Helper function to convert target metadata to a live buffer. + (path-to-buffer + (lambda (alias path) + (let* ((project-info + (enkan-repl--get-project-info-from-directories + alias target-directories)) + (target-project (and (consp project-info) + (car project-info))) + (instance + (enkan-repl--target-alias-instance-for-path + alias path)) + (target-names + (delete-dups + (delq nil + (list alias current-project target-project))))) + (cl-find-if + (lambda (buffer) + (enkan-repl--buffer-matches-target-p + buffer workspace-id path instance target-names)) + (buffer-list))))) + ;; Helper function to check if buffer exists. + (get-active-buffers + (lambda (paths) + (cl-loop for (alias . path) in paths + for buffer = (funcall path-to-buffer alias path) + when buffer + collect (list :alias alias + :buffer buffer + :path path))))) ;; Check if we have active buffers (let ((active-pairs (if project-paths (funcall get-active-buffers project-paths) ;; Fallback to all available buffers (cl-loop for buffer in (enkan-repl--get-available-buffers (buffer-list)) - collect (cons nil buffer))))) + collect (list :alias nil + :buffer buffer + :path (and (buffer-name buffer) + (enkan-repl--buffer-name->path + (buffer-name buffer)))))))) (if (null active-pairs) (list :status 'no-buffers :message (enkan-repl--no-active-sessions-message)) @@ -1866,27 +1990,43 @@ Returns a plist with :status and other keys." ;; Priority 1: prefix-arg based selection ((and pfx (numberp pfx) (> pfx 0)) (if (<= pfx (length active-pairs)) - (list :status 'selected - :buffer (cdr (nth (1- pfx) active-pairs))) + (let ((pair (nth (1- pfx) active-pairs))) + (list :status 'selected + :buffer (plist-get pair :buffer) + :path (plist-get pair :path))) (list :status 'invalid :message (format "Invalid prefix arg: %d (only %d buffers available)" pfx (length active-pairs))))) ;; Priority 2: alias based selection ((and resolved-alias (stringp resolved-alias) (not (string= "" resolved-alias))) - (let ((matching-pair (assoc resolved-alias active-pairs))) + (let ((matching-pair + (cl-find resolved-alias active-pairs + :key (lambda (pair) + (plist-get pair :alias)) + :test #'string=))) (if matching-pair (list :status 'selected - :buffer (cdr matching-pair)) + :buffer (plist-get matching-pair :buffer) + :path (plist-get matching-pair :path)) (list :status 'invalid :message (format "No buffer found for alias '%s'" resolved-alias))))) ;; Priority 3: auto-select if single buffer ((= 1 (length active-pairs)) - (list :status 'single - :buffer (cdr (car active-pairs)))) + (let ((pair (car active-pairs))) + (list :status 'single + :buffer (plist-get pair :buffer) + :path (plist-get pair :path)))) ;; Priority 4: multiple buffers - need interactive selection (t (list :status 'needs-selection - :buffers (mapcar #'cdr active-pairs)))))))) + :buffers (mapcar (lambda (pair) + (plist-get pair :buffer)) + active-pairs) + :buffer-paths + (mapcar (lambda (pair) + (cons (plist-get pair :buffer) + (plist-get pair :path))) + active-pairs)))))))) (defun enkan-repl--select-project (project-paths current-project prompt _action-fn validation-fn) "Handle project selection based on PROJECT-PATHS. @@ -1960,18 +2100,21 @@ Returns a plist with :status and other relevant keys." (let* ((buffer (plist-get resolution :buffer)) (buffer-name (buffer-name buffer)) ;; Extract path from buffer name format: *ws:01 enkan:/path/to/project* - (decoded-path (enkan-repl--buffer-name->path buffer-name))) + (decoded-path (or (plist-get resolution :path) + (enkan-repl--buffer-name->path buffer-name)))) (list :status 'selected :path decoded-path))) ('needs-selection (let* ((available-buffers (plist-get resolution :buffers)) + (buffer-paths (plist-get resolution :buffer-paths)) (choices (enkan-repl--build-buffer-selection-choices available-buffers)) (selection (hmenu prompt choices))) (if selection (let* ((selected-buffer (cdr (assoc selection choices))) (buffer-name (buffer-name selected-buffer)) ;; Extract path from buffer name format: *ws:01 enkan:/path/to/project* - (decoded-path (enkan-repl--buffer-name->path buffer-name))) + (decoded-path (or (cdr (assq selected-buffer buffer-paths)) + (enkan-repl--buffer-name->path buffer-name)))) (list :status 'selected :path decoded-path)) (list :status 'cancelled @@ -2113,12 +2256,11 @@ Returns the buffers that belong to the current workspace and represent a live terminal session per `enkan-repl--buffer-alive-as-terminal-p' \(backend agnostic: covers eat process-attached buffers and tmux mirror buffers)." - (let ((current-ws enkan-repl--current-workspace)) + (let ((current-ws (or enkan-repl--current-workspace "01"))) (seq-filter (lambda (buffer) (and (bufferp buffer) - (buffer-name buffer) - (enkan-repl--buffer-name-matches-workspace - (buffer-name buffer) current-ws) + (enkan-repl--buffer-matches-workspace-p + buffer current-ws) (enkan-repl--buffer-alive-as-terminal-p buffer))) buffer-list))) @@ -2141,8 +2283,16 @@ Resolution priority: `prefix-arg' → alias → nil (for interactive selection). (let* ((resolved-project (cdr alias-entry)) (matching-buffers (seq-filter (lambda (buf) - (let ((buffer-project (enkan-repl--extract-project-name (buffer-name buf)))) - (string= resolved-project buffer-project))) + (let* ((buffer-project + (enkan-repl--extract-project-name + (buffer-name buf))) + (tmux-window + (and (fboundp 'enkan-repl--terminal-tmux--id-window) + (enkan-repl--terminal-tmux--id-window + (enkan-repl--buffer-tmux-id buf))))) + (or (string= resolved-project buffer-project) + (string= resolved-project + (or tmux-window ""))))) buffers))) (car matching-buffers))))) ;; Priority 3: return nil for interactive selection diff --git a/examples/window-layouts.el b/examples/window-layouts.el index 773fd57..f83a500 100644 --- a/examples/window-layouts.el +++ b/examples/window-layouts.el @@ -92,6 +92,7 @@ not returned." (enkan-repl--layout-session-project-info project-name enkan-repl-target-directories)) (resolved-project-name (or (car project-info) project-name)) + (project-path (cdr project-info)) (setup (enkan-repl--setup-window-terminal-buffer-pure nil session-number (enkan-repl--ws-session-list) enkan-repl-target-directories)) @@ -104,14 +105,19 @@ not returned." (cl-find-if (lambda (buffer) (let ((name (buffer-name buffer))) - (and name - (enkan-repl--buffer-name-matches-workspace - name enkan-repl--current-workspace) - (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)))) + (or (and project-path + (enkan-repl--buffer-matches-target-p + buffer enkan-repl--current-workspace project-path instance + (list project-name resolved-project-name)) + (enkan-repl--buffer-alive-as-terminal-p buffer)) + (and name + (enkan-repl--buffer-name-matches-workspace + name enkan-repl--current-workspace) + (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)))))) (defun enkan-repl--registered-session-buffers () @@ -394,6 +400,8 @@ Category: Utilities" (error "No current project active. Run enkan-repl-setup first")) (unless enkan-repl--current-workspace (error "No current workspace active")) + (when (fboundp 'enkan-repl--restore-current-workspace-sessions-from-live-terminals) + (enkan-repl--restore-current-workspace-sessions-from-live-terminals)) ;; Count registered live buffers for current workspace. Do not use every ;; mirror buffer with the workspace prefix: stale tmux windows can recreate ;; stray mirrors asynchronously, and those must not drive the layout. diff --git a/test/enkan-repl-core-test.el b/test/enkan-repl-core-test.el index 6de65f9..2f15dbc 100644 --- a/test/enkan-repl-core-test.el +++ b/test/enkan-repl-core-test.el @@ -418,6 +418,48 @@ #'string=)) :target-directories))))))) +(ert-deftest test-enkan-repl-tmux-reattach-restores-missing-session-list () + "Reattach should restore sessions when persisted state lost session-list." + (let ((saved-workspaces + '(("02" :current-project "lat" + :session-list nil + :session-counter 0 + :project-aliases ("lat") + :target-directories + (("lat" . ("lat" . "/Users/me/dev/lattice-system")))))) + (enkan-repl-terminal-backend 'tmux) + (enkan-repl--workspaces nil) + (enkan-repl--current-workspace nil) + (enkan-repl--current-project nil) + (enkan-repl-session-list nil) + (enkan-repl--session-counter 0) + (enkan-repl-project-aliases nil) + (enkan-repl-target-directories nil)) + (cl-letf (((symbol-function 'enkan-repl-state-load) + (lambda (&optional _file) + (list :workspaces saved-workspaces :current "02"))) + ((symbol-function 'enkan-repl-state--list-live-tmux-sessions) + (lambda (_prefix) + '("enkan-02"))) + ((symbol-function 'enkan-repl--terminal-tmux--list-window-cwds) + (lambda (_session) + '(("lattice-system" . "/Users/me/dev/lattice-system")))) + ((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) + (lambda (&optional _file) t))) + (let ((result (enkan-repl-tmux-reattach))) + (should result) + (should (equal "02" enkan-repl--current-workspace)) + (should (equal '((1 . "lat")) enkan-repl-session-list)) + (should (= 1 enkan-repl--session-counter)) + (should (equal '((1 . "lat")) + (plist-get (cdr (assoc "02" enkan-repl--workspaces + #'string=)) + :session-list))))))) + (ert-deftest test-enkan-repl-tmux-reattach-ensures-already-current-state () "Manual tmux reattach recreates mirrors even when state is already current." (let* ((saved-workspaces diff --git a/test/enkan-repl-functions-test.el b/test/enkan-repl-functions-test.el index adda9be..66be33d 100644 --- a/test/enkan-repl-functions-test.el +++ b/test/enkan-repl-functions-test.el @@ -201,6 +201,27 @@ (when (buffer-live-p buffer2) (kill-buffer buffer2))))) +(ert-deftest test-enkan-repl--resolve-send-target-tmux-fallback-buffer () + "Send target resolution should match tmux fallback buffers by tmux id." + (let* ((buffer (generate-new-buffer "*tmux enkan-02:lattice-system|%1*")) + (enkan-repl--current-workspace "02") + (projects '(("lat" . ("lat")))) + (target-directories + '(("lat" . ("lat" . "/Users/sekine/dev/self/lattice-system/"))))) + (unwind-protect + (progn + (with-current-buffer buffer + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (let ((result (enkan-repl--resolve-send-target + nil "lat" "lat" projects target-directories))) + (should (equal (plist-get result :status) 'selected)) + (should (eq (plist-get result :buffer) buffer)) + (should (equal (plist-get result :path) + "/Users/sekine/dev/self/lattice-system/")))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + ;; Tests for enkan-repl--target-directory-info (ert-deftest test-enkan-repl--target-directory-info () "Test unified project selection handling." @@ -268,6 +289,27 @@ (should (equal (plist-get result :status) 'invalid)) (should (equal (plist-get result :path) "/invalid/path")))) +(ert-deftest test-enkan-repl--target-directory-info-tmux-fallback-buffer () + "Prefix directory selection should recover the path for tmux fallback buffers." + (let* ((buffer (generate-new-buffer "*tmux enkan-02:lattice-system|%1*")) + (enkan-repl--current-workspace "02") + (projects '(("lat" . ("lat")))) + (target-directories + '(("lat" . ("lat" . "/Users/sekine/dev/self/lattice-system/"))))) + (unwind-protect + (progn + (with-current-buffer buffer + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (let ((result (enkan-repl--target-directory-info + "lat" projects target-directories + "Select project:" #'stringp 1))) + (should (equal (plist-get result :status) 'selected)) + (should (equal (plist-get result :path) + "/Users/sekine/dev/self/lattice-system/")))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest test-enkan-repl-open-project-directory-current-project-name () "Open project directory when current project is a target project name." (let ((tmpdir (file-name-as-directory diff --git a/test/enkan-repl-workspace-buffer-count-test.el b/test/enkan-repl-workspace-buffer-count-test.el index fd793c9..4ac55b1 100644 --- a/test/enkan-repl-workspace-buffer-count-test.el +++ b/test/enkan-repl-workspace-buffer-count-test.el @@ -66,5 +66,20 @@ (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(ert-deftest test-enkan-repl--get-workspace-buffer-count-tmux-fallback-name () + "Workspace counts should include tmux fallback buffers by tmux id workspace." + (let ((tmux (generate-new-buffer "*tmux enkan-02:lattice-system|%1*"))) + (unwind-protect + (progn + (with-current-buffer tmux + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (should (= 1 (enkan-repl--get-workspace-buffer-count-pure + (list tmux) "02"))) + (should (= 0 (enkan-repl--get-workspace-buffer-count-pure + (list tmux) "03")))) + (when (buffer-live-p tmux) + (kill-buffer tmux))))) + (provide 'enkan-repl-workspace-buffer-count-test) ;;; enkan-repl-workspace-buffer-count-test.el ends here diff --git a/test/enkan-repl-workspace-scope-test.el b/test/enkan-repl-workspace-scope-test.el index c169fce..1852c0c 100644 --- a/test/enkan-repl-workspace-scope-test.el +++ b/test/enkan-repl-workspace-scope-test.el @@ -79,6 +79,26 @@ ;; Restore original workspace (setq enkan-repl--current-workspace original-ws)))) +(ert-deftest test-enkan-repl--get-available-buffers-tmux-fallback-workspace () + "Available buffer filtering should use tmux id workspace for fallback names." + (let* ((enkan-repl--current-workspace "02") + (buffer-ws02 (generate-new-buffer "*tmux enkan-02:lattice-system|%1*")) + (buffer-ws03 (generate-new-buffer "*tmux enkan-03:lattice-system|%2*"))) + (unwind-protect + (progn + (with-current-buffer buffer-ws02 + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (with-current-buffer buffer-ws03 + (setq-local enkan-repl--tmux-mirror-id + "enkan-03:lattice-system|%2")) + (should (equal (enkan-repl--get-available-buffers + (list buffer-ws02 buffer-ws03)) + (list buffer-ws02)))) + (dolist (buffer (list buffer-ws02 buffer-ws03)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + (ert-deftest test-enkan-repl--get-buffer-for-directory-with-workspace () "Test that get-buffer-for-directory filters by workspace." ;; Save current workspace @@ -110,6 +130,21 @@ ;; Restore original workspace (setq enkan-repl--current-workspace original-ws)))) +(ert-deftest test-enkan-repl--get-buffer-for-directory-tmux-fallback () + "Directory lookup should match tmux fallback buffers by window and workspace id." + (let* ((enkan-repl--current-workspace "02") + (buffer (generate-new-buffer "*tmux enkan-02:lattice-system|%1*"))) + (unwind-protect + (progn + (with-current-buffer buffer + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (should (eq (enkan-repl--get-buffer-for-directory + "/Users/sekine/dev/self/lattice-system/") + buffer))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (provide 'enkan-repl-workspace-scope-test) -;;; enkan-repl-workspace-scope-test.el ends here \ No newline at end of file +;;; enkan-repl-workspace-scope-test.el ends here diff --git a/test/enkan-repl-workspace-switch-layout-test.el b/test/enkan-repl-workspace-switch-layout-test.el index d8a1e0e..8c4f17b 100644 --- a/test/enkan-repl-workspace-switch-layout-test.el +++ b/test/enkan-repl-workspace-switch-layout-test.el @@ -201,6 +201,66 @@ (when (buffer-live-p good) (kill-buffer good))))) +(ert-deftest test-workspace-layout-counts-tmux-fallback-name-by-id () + "C-M-l should count tmux mirrors before cwd-based buffer renaming finishes." + (let* ((project-dir "/Users/sekine/dev/self/lattice-system/") + (good (generate-new-buffer "*tmux enkan-02:lattice-system|%1*")) + (enkan-repl--current-workspace "02") + (enkan-repl--current-project "lat") + (enkan-repl-session-list '((1 . "lat"))) + (enkan-repl-target-directories + `(("lat" . ("lat" . ,project-dir)))) + (called nil)) + (unwind-protect + (progn + (with-current-buffer good + (setq-local enkan-repl--tmux-mirror-id + "enkan-02:lattice-system|%1")) + (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-restores-empty-session-list-from-tmux () + "C-M-l should self-repair an empty session-list from live tmux targets." + (let ((enkan-repl-terminal-backend 'tmux) + (enkan-repl--current-workspace "02") + (enkan-repl--current-project "lat") + (enkan-repl-session-list nil) + (enkan-repl--session-counter 0) + (enkan-repl-project-aliases '("lat")) + (enkan-repl-projects '(("lat" . ("lat")))) + (enkan-repl-target-directories + '(("lat" . ("lat" . "/Users/sekine/dev/self/lattice-system/")))) + (called nil) + created-buffer) + (unwind-protect + (cl-letf (((symbol-function 'enkan-repl--terminal-list) + (lambda () '("enkan-02:lattice-system|%1"))) + ((symbol-function 'enkan-repl--terminal-tmux--mirror-make) + (lambda (id _defer-refresh path) + (setq created-buffer + (get-buffer-create + (let ((enkan-repl--current-workspace "02")) + (enkan-repl--path->buffer-name path)))) + (with-current-buffer created-buffer + (setq-local enkan-repl--tmux-mirror-id id)) + created-buffer)) + ((symbol-function 'enkan-repl--save-workspace-state) + (lambda (&optional _workspace-id) t)) + ((symbol-function 'enkan-repl-setup-1session-layout) + (lambda () (setq called 'one)))) + (enkan-repl-setup-current-project-layout) + (should (equal enkan-repl-session-list '((1 . "lat")))) + (should (= enkan-repl--session-counter 1)) + (should (eq called 'one))) + (when (buffer-live-p created-buffer) + (kill-buffer created-buffer))))) + (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/")