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 diff --git a/eca-process.el b/eca-process.el index 5701bdf..3e7dfe7 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,14 +616,21 @@ 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)))))))) + (if (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))) diff --git a/eca-util.el b/eca-util.el index a609f8d..c54e6df 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,44 +340,77 @@ 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))) + (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 `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) + (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))) (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) (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))) - (to (funcall ensure-slash - (if (not expand-from-p) (expand-file-name to-raw) to-raw)))) + (if (and expand-from-p (not (file-remote-p from-raw))) + (expand-file-name from-raw) + from-raw))) + (to-expanded (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)) + ((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))) (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)) (defun eca-info (format &rest 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