From a86814fda229ff0cd6b02fa91f3ace31ffbe3c0b Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Sun, 10 May 2026 09:56:57 -0400 Subject: [PATCH 1/7] Derive path mappings from TRAMP workspace folders Merge explicit prefix-map entries with implicit mappings from TRAMP folders. Strip TRAMP prefixes in local-to-remote so the server sees local paths. --- eca-util.el | 64 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/eca-util.el b/eca-util.el index a609f8d..40e0bca 100644 --- a/eca-util.el +++ b/eca-util.el @@ -313,7 +313,7 @@ workspace folder. Falls back to \"unknown\"." (concat eca--uri-file-prefix (--> path (expand-file-name it) - (or (file-remote-p it 'localname t) it) + (or (file-remote-p it 'localname) it) (eca--path-local-to-remote it)))) (defun eca--uri-to-path (uri) @@ -340,19 +340,42 @@ The longest matching prefix wins. :type '(alist :key-type string :value-type string) :group 'eca) +(defun eca--path-mappings () + "Return merged path mappings for translation. +Combines explicit `eca-local-to-remote-prefix-map' entries with +implicit mappings derived from TRAMP workspace folders. Each +TRAMP folder like \"/docker:container:/workspace/project\" yields +a mapping (\"/docker:container:/workspace/project\" . \"/workspace/project\")." + (append + eca-local-to-remote-prefix-map + (when-let* ((session (ignore-errors (eca-session))) + (folders (eca--session-workspace-folders session))) + (seq-keep + (lambda (folder) + (when-let (remote (file-remote-p folder)) + (cons folder (file-local-name folder)))) + folders)))) + (defun eca--path--translate (path from-fn to-fn expand-from-p) - "Translate PATH using `eca-local-to-remote-prefix-map'. + "Translate PATH using explicit and TRAMP-derived path mappings. FROM-FN extracts the side of each mapping to match against PATH; -TO-FN extracts the side to substitute in. If EXPAND-FROM-P is non-nil, -expand the from-side path. The longest matching prefix wins." +TO-FN extracts the side to substitute in. If EXPAND-FROM-P is +non-nil, expand the from-side path. The longest matching prefix +wins; explicit user mappings take priority over TRAMP-derived ones +for equal-length prefixes." (let* ((ensure-slash (lambda (s) (if (string-suffix-p "/" s) s (concat s "/")))) - (sorted (sort (copy-sequence eca-local-to-remote-prefix-map) + (sorted (sort (copy-sequence (eca--path-mappings)) (lambda (a b) (let ((la (length (funcall from-fn a))) (lb (length (funcall from-fn b)))) (if (= la lb) - (> (length (funcall to-fn a)) - (length (funcall to-fn b))) + (let ((explicit-a (member a eca-local-to-remote-prefix-map)) + (explicit-b (member b eca-local-to-remote-prefix-map))) + (cond + ((and explicit-a (not explicit-b)) t) + ((and (not explicit-a) explicit-b) nil) + (t (> (length (funcall to-fn a)) + (length (funcall to-fn b)))))) (> la lb))))))) (or (seq-some (lambda (m) @@ -371,15 +394,32 @@ expand the from-side path. The longest matching prefix wins." path))) (defun eca--path-local-to-remote (path) - "Translate a local Emacs PATH to a remote path using `eca-local-to-remote-prefix-map'. -Uses the longest (most specific) matching prefix to avoid ambiguity." - (eca--path--translate (expand-file-name path) #'car #'cdr t)) + "Translate a local Emacs PATH to a remote path. +First strips any TRAMP prefix from PATH, then applies path +mappings (explicit `eca-local-to-remote-prefix-map' plus TRAMP +workspace folder mappings). The longest matching prefix wins." + (let ((local-path (or (file-remote-p path 'localname) path))) + (eca--path--translate (expand-file-name local-path) #'car #'cdr t))) (defun eca--path-remote-to-local (path) - "Translate a remote server PATH to a local Emacs path using `eca-local-to-remote-prefix-map'. -Uses the longest (most specific) matching prefix to avoid ambiguity." + "Translate a remote server PATH to a local Emacs path. +Applies path mappings (explicit `eca-local-to-remote-prefix-map' +plus TRAMP workspace folder mappings). The longest matching prefix +wins; explicit user mappings take priority over TRAMP-derived ones." (eca--path--translate path #'cdr #'car nil)) +(defmacro eca--with-remote-context (path &rest body) + "Execute BODY with `default-directory' set for PATH's context. +When PATH is remote (has a TRAMP prefix), bind `default-directory' +to the remote directory so `shell-command' runs on the remote host. +Use `file-local-name' on paths passed to shell commands within BODY." + (declare (indent 1)) + `(let ((default-directory (if-let ((remote (file-remote-p ,path))) + (or (file-name-directory ,path) + default-directory) + default-directory))) + ,@body)) + (defun eca-info (format &rest args) "Display eca info message with FORMAT with ARGS." (message "%s :: %s" (propertize "ECA" 'face 'success) (apply #'format format args))) From c8cd837165f84d654263efee6f3970947e403b6b Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Sun, 10 May 2026 09:58:03 -0400 Subject: [PATCH 2/7] Test TRAMP path translation and workspace mappings --- eca-util.el | 12 -------- test/eca-util-test.el | 70 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/eca-util.el b/eca-util.el index 40e0bca..68335f7 100644 --- a/eca-util.el +++ b/eca-util.el @@ -408,18 +408,6 @@ plus TRAMP workspace folder mappings). The longest matching prefix wins; explicit user mappings take priority over TRAMP-derived ones." (eca--path--translate path #'cdr #'car nil)) -(defmacro eca--with-remote-context (path &rest body) - "Execute BODY with `default-directory' set for PATH's context. -When PATH is remote (has a TRAMP prefix), bind `default-directory' -to the remote directory so `shell-command' runs on the remote host. -Use `file-local-name' on paths passed to shell commands within BODY." - (declare (indent 1)) - `(let ((default-directory (if-let ((remote (file-remote-p ,path))) - (or (file-name-directory ,path) - default-directory) - default-directory))) - ,@body)) - (defun eca-info (format &rest args) "Display eca info message with FORMAT with ARGS." (message "%s :: %s" (propertize "ECA" 'face 'success) (apply #'format format args))) diff --git a/test/eca-util-test.el b/test/eca-util-test.el index 2b1de98..9eb8383 100644 --- a/test/eca-util-test.el +++ b/test/eca-util-test.el @@ -127,5 +127,75 @@ (expect (eca--path-remote-to-local "/workspace/win-project/src/main.rs") :to-equal (expand-file-name "C:/Users/me/ws/win-project/src/main.rs"))))) +(describe "eca--path-local-to-remote with TRAMP" + (it "strips TRAMP prefix before applying prefix map" + (let ((eca-local-to-remote-prefix-map + '(("/workspace/project" . "/mnt/project")))) + (expect (eca--path-local-to-remote + "/docker:container:/workspace/project/src/file.el") + :to-equal "/mnt/project/src/file.el"))) + + (it "strips TRAMP and returns expanded local part when no mapping matches" + (let ((eca-local-to-remote-prefix-map nil)) + (expect (eca--path-local-to-remote + "/docker:container:/workspace/project/src/file.el") + :to-equal (expand-file-name + "/workspace/project/src/file.el")))) + + (it "handles non-TRAMP paths unchanged (no double-stripping)" + (let ((eca-local-to-remote-prefix-map + '(("/Users/me/dev" . "/workspace")))) + (expect (eca--path-local-to-remote "/Users/me/dev/src/file.el") + :to-equal "/workspace/src/file.el")))) + +(describe "eca--path-remote-to-local with TRAMP workspace folders" + (it "uses TRAMP workspace folder when no explicit mapping matches" + (let ((session (make-eca--session)) + (eca-local-to-remote-prefix-map nil)) + (setf (eca--session-workspace-folders session) + '("/docker:container:/workspace/project")) + (spy-on 'eca-session :and-return-value session) + (expect (eca--path-remote-to-local "/workspace/project/src/file.el") + :to-equal "/docker:container:/workspace/project/src/file.el"))) + + (it "matches the most specific TRAMP workspace folder" + (let ((session (make-eca--session)) + (eca-local-to-remote-prefix-map nil)) + (setf (eca--session-workspace-folders session) + '("/docker:a:/workspace" + "/docker:b:/workspace/project")) + (spy-on 'eca-session :and-return-value session) + (expect (eca--path-remote-to-local "/workspace/project/src/file.el") + :to-equal "/docker:b:/workspace/project/src/file.el"))) + + (it "handles exact TRAMP workspace folder match (no trailing slash)" + (let ((session (make-eca--session)) + (eca-local-to-remote-prefix-map nil)) + (setf (eca--session-workspace-folders session) + '("/docker:container:/workspace/project")) + (spy-on 'eca-session :and-return-value session) + (expect (eca--path-remote-to-local "/workspace/project") + :to-equal "/docker:container:/workspace/project"))) + + (it "prefers explicit prefix map over TRAMP workspace folders" + (let ((session (make-eca--session)) + (eca-local-to-remote-prefix-map + '(("/Users/me/dev" . "/workspace/project")))) + (setf (eca--session-workspace-folders session) + '("/docker:container:/workspace/project")) + (spy-on 'eca-session :and-return-value session) + (expect (eca--path-remote-to-local "/workspace/project/src/file.el") + :to-equal (expand-file-name + "/Users/me/dev/src/file.el")))) + + (it "returns path unchanged when neither mapping nor TRAMP matches" + (let ((session (make-eca--session)) + (eca-local-to-remote-prefix-map nil)) + (setf (eca--session-workspace-folders session) + '("/Users/me/local-project")) + (spy-on 'eca-session :and-return-value session) + (expect (eca--path-remote-to-local "/unmapped/path/file.el") + :to-equal "/unmapped/path/file.el")))) + (provide 'eca-util-test) ;;; eca-util-test.el ends here From 064695b08744ddca163b3bca3a73f39b7bdbf552 Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:29:27 +0000 Subject: [PATCH 3/7] Document TRAMP / remote host support in README Add a "TRAMP / remote hosts" section explaining that auto-install is local only, users must install eca on the remote PATH or set eca-custom-command, and that path mappings are derived from TRAMP workspace folders automatically. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 2198f53..6e2ac1d 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,32 @@ and `eca-doom-workspace-tab-running-face` faces, or disable with: (setq eca-doom-workspace-tabs nil) ``` +## TRAMP / remote hosts + +ECA can run when the current buffer is on a remote file (TRAMP: Docker, SSH, etc.). In +that case eca-emacs starts the ECA server on the remote host using TRAMP’s process +file handler, so the server runs where your project files live. + +Auto-install of the ECA binary is **local only**. On a remote host you must either +install the `eca` binary on the remote machine so it appears on `PATH`, or point +`eca-custom-command` at the remote binary. See the [installation +guide](https://eca.dev/installation/) for how to install `eca`. To use a specific +path on the remote, set for example: + +```elisp +(setq eca-custom-command '("/workspace/.local/bin/eca" "server")) +``` + +**Path translation:** by default, eca-emacs derives local-to-remote path prefix +mappings from your TRAMP workspace folders so file URIs sent to the server match +paths inside the container or remote host. For manual control, set +`eca-local-to-remote-prefix-map`, for example: + +```elisp +(setq eca-local-to-remote-prefix-map + '(("/Users/me/dev/project" . "/workspace/project"))) +``` + ## Sandboxing You can run the eca server under any sandbox tool that wraps a command From 19e3a5151185c3a8949ead1dae101ea63d0fb071 Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:49:55 +0000 Subject: [PATCH 4/7] Detect TRAMP hosts in server discovery Refuse auto-download on remote TRAMP hosts and show a clear error message directing users to install eca on the remote PATH or set eca-custom-command. Use executable-find with REMOTE argument to search the remote PATH, and make server-version detection TRAMP-aware by running --version through the remote shell when the binary lives on a remote host. --- eca-process.el | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/eca-process.el b/eca-process.el index 5701bdf..5d94a45 100644 --- a/eca-process.el +++ b/eca-process.el @@ -135,7 +135,7 @@ This affects update detection at session start and on `eca-restart', so longer-running Emacs sessions can still pick up newer eca releases. See also `eca-server-check-updates'." :type '(choice (const :tag "Never expire" nil) - (integer :tag "Seconds")) + (integer :tag "Seconds")) :group 'eca) (defvar eca-process--releases-cache nil @@ -402,7 +402,7 @@ the given VERSION." (defun eca-process--server-command () "Return the command to start server." - (let ((system-command (executable-find "eca"))) + (let ((system-command (executable-find "eca" (file-remote-p default-directory)))) (cond (eca-custom-command (list :decision 'custom :command eca-custom-command)) @@ -411,6 +411,10 @@ the given VERSION." (list :decision 'system :command (list system-command "server"))) + ((file-remote-p default-directory) + (list :decision 'error-download + :message "ECA not found on remote host. Install `eca` on the remote PATH or set `eca-custom-command` to the remote binary path. ECA does not auto-install over TRAMP.")) + ((and (not (f-exists? eca-server-install-path)) (not (eca-process--get-latest-server-version))) (list :decision 'error-download @@ -551,7 +555,7 @@ Call HANDLE-MSG for new msgs processed." :sentinel (lambda (process exit-str) (unless (process-live-p process) (when-let* ((name (eca-process--stderr-buffer-name session)) - (buf (get-buffer name))) + (buf (get-buffer name))) (with-current-buffer buf (rename-buffer (concat (buffer-name) ":closed") t) (setq-local mode-line-format '("*Closed session*")))) @@ -612,16 +616,23 @@ Call HANDLE-MSG for new msgs processed." (defun eca-process--server-version () "Return the server version by running the eca binary with --version." (when-let* ((binary (or (car eca-custom-command) - (executable-find "eca") - (when (f-exists? eca-server-install-path) - eca-server-install-path))) + (executable-find "eca" (file-remote-p default-directory)) + (and (f-exists? eca-server-install-path) + eca-server-install-path))) (output (ignore-errors (string-trim - (shell-command-to-string - (format "%s --version 2>/dev/null" - (shell-quote-argument (expand-file-name binary)))))))) - (unless (string-empty-p output) - output))) + (if-let ((remote (file-remote-p binary))) + (let ((default-directory (file-name-directory binary))) + (shell-command-to-string + (format "%s --version 2>/dev/null" + (shell-quote-argument + (file-local-name binary))))) + (shell-command-to-string + (format "%s --version 2>/dev/null" + (shell-quote-argument + (expand-file-name binary)))))))))) + (unless (string-empty-p output) + output)) ;;;###autoload (defun eca-show-stderr () From a317db1a7d4ae55c4914c8d41c3ef47b3d46aa78 Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:58:09 -0400 Subject: [PATCH 5/7] Fix 28.1 support --- eca-process.el | 8 ++++---- eca-util.el | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/eca-process.el b/eca-process.el index 5d94a45..3e7dfe7 100644 --- a/eca-process.el +++ b/eca-process.el @@ -621,7 +621,7 @@ Call HANDLE-MSG for new msgs processed." eca-server-install-path))) (output (ignore-errors (string-trim - (if-let ((remote (file-remote-p binary))) + (if (file-remote-p binary) (let ((default-directory (file-name-directory binary))) (shell-command-to-string (format "%s --version 2>/dev/null" @@ -630,9 +630,9 @@ Call HANDLE-MSG for new msgs processed." (shell-command-to-string (format "%s --version 2>/dev/null" (shell-quote-argument - (expand-file-name binary)))))))))) - (unless (string-empty-p output) - output)) + (expand-file-name binary))))))))) + (unless (string-empty-p output) + output))) ;;;###autoload (defun eca-show-stderr () diff --git a/eca-util.el b/eca-util.el index 68335f7..1f4fc51 100644 --- a/eca-util.el +++ b/eca-util.el @@ -350,11 +350,12 @@ a mapping (\"/docker:container:/workspace/project\" . \"/workspace/project\")." eca-local-to-remote-prefix-map (when-let* ((session (ignore-errors (eca-session))) (folders (eca--session-workspace-folders session))) - (seq-keep - (lambda (folder) - (when-let (remote (file-remote-p folder)) - (cons folder (file-local-name folder)))) - folders)))) + (delq nil + (mapcar + (lambda (folder) + (when (file-remote-p folder) + (cons folder (file-local-name folder)))) + folders))))) (defun eca--path--translate (path from-fn to-fn expand-from-p) "Translate PATH using explicit and TRAMP-derived path mappings. From 1d0f2d9e83e4b412c70d2274e731c4f66deedf34 Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:28:03 -0400 Subject: [PATCH 6/7] Fix specs --- eca-util.el | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/eca-util.el b/eca-util.el index 1f4fc51..1ae6dc0 100644 --- a/eca-util.el +++ b/eca-util.el @@ -383,9 +383,13 @@ for equal-length prefixes." (let* ((from-raw (funcall from-fn m)) (to-raw (funcall to-fn m)) (from (funcall ensure-slash - (if expand-from-p (expand-file-name from-raw) from-raw))) + (if (and expand-from-p (not (file-remote-p from-raw))) + (expand-file-name from-raw) + from-raw))) (to (funcall ensure-slash - (if (not expand-from-p) (expand-file-name to-raw) to-raw)))) + (if (and (not expand-from-p) (not (file-remote-p to-raw))) + (expand-file-name to-raw) + to-raw)))) (cond ((string= path (directory-file-name from)) (directory-file-name to)) From 00a4affdc93f0726f53a31f2363ba5848080c29f Mon Sep 17 00:00:00 2001 From: Andi Violet Lavera <49383564+AndiLavera@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:36:42 -0400 Subject: [PATCH 7/7] Small clean up --- eca-util.el | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/eca-util.el b/eca-util.el index 1ae6dc0..c54e6df 100644 --- a/eca-util.el +++ b/eca-util.el @@ -365,6 +365,7 @@ non-nil, expand the from-side path. The longest matching prefix wins; explicit user mappings take priority over TRAMP-derived ones for equal-length prefixes." (let* ((ensure-slash (lambda (s) (if (string-suffix-p "/" s) s (concat s "/")))) + (strip-slash (lambda (s) (if (string-suffix-p "/" s) (substring s 0 -1) s))) (sorted (sort (copy-sequence (eca--path-mappings)) (lambda (a b) (let ((la (length (funcall from-fn a))) @@ -386,15 +387,14 @@ for equal-length prefixes." (if (and expand-from-p (not (file-remote-p from-raw))) (expand-file-name from-raw) from-raw))) - (to (funcall ensure-slash - (if (and (not expand-from-p) (not (file-remote-p to-raw))) + (to-expanded (if (and (not expand-from-p) (not (file-remote-p to-raw))) (expand-file-name to-raw) - to-raw)))) + to-raw))) (cond - ((string= path (directory-file-name from)) - (directory-file-name to)) + ((string= path (funcall strip-slash from)) + (funcall strip-slash to-expanded)) ((string-prefix-p from path) - (concat to (substring path (length from))))))) + (concat (funcall ensure-slash to-expanded) (substring path (length from))))))) sorted) path)))