Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 20 additions & 9 deletions eca-process.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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*"))))
Expand Down Expand Up @@ -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)))

Expand Down
69 changes: 51 additions & 18 deletions eca-util.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions test/eca-util-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading