Repository navigation
Conversation
Add herdr_client::remote_file, which copies a file off an SSH host and writes it back over the endpoint's noninteractive SSH trust, for the upcoming remote file links in the GUI. POSIX hosts run /bin/sh scripts; Windows OpenSSH hosts run Windows PowerShell 5.1+ scripts through -EncodedCommand. The path travels quoted in the script; file bytes travel only on stdout and stdin. Both shells share one exit-code contract for missing, not-a-file, too-large, symlink, no-hasher, and conflict refusals. A push writes a private temporary file beside the target and renames it over the target only while the host's file still matches the expected SHA-256 (or is absent, or anything). An existing POSIX file keeps its permission bits; a new one gets the login umask's default. Tests run the POSIX scripts through a local /bin/sh and, opt-in, the PowerShell scripts through a local powershell.exe and pwsh.
A link-modifier click on a path printed in a pane on an SSH device did nothing, since this machine cannot open that host's paths. Such paths are now links by their shape: a click fetches the file over the device's SSH trust into a per-launch cache and opens the copy in the system's application, or the folder holding it when it is not a known document, under the same rule as local paths. Windows OpenSSH hosts are recognised by a drive or UNC path, or a Windows working directory for a relative path, and use PowerShell; other hosts use /bin/sh. Relative paths join the pane's cwd with the host's separator, and `~` is left for the host to expand. Hover never contacts the host. Plugin link handlers keep the first claim. WSL and cloud panes are unchanged, as are SSH panes on Windows clients, which have no SSH children. The cache is removed at quit, and folders left by earlier launches are swept on first use.
A copy fetched by clicking a path in an SSH pane is now watched for saves. The first save asks whether to upload it: Upload, Upload future saves without asking, or Not now. Later saves follow that answer, and an automatic upload reports itself in a flash. Every upload sends the hash the host's file had when it was last synced. When the file changed on the host since then, nothing is written and a dialog offers Keep both (uploads beside it as name.conflict.ext), Overwrite anyway, or Re-fetch. Copies over 64 MB are not uploaded. The client's push now carries the exact byte count, and the host refuses to replace its file with a temp file that holds fewer bytes, so a dropped connection or cancelled upload leaves it unchanged. A click that fetches a copy again keeps a save made while the fetch ran. The host, shell, and path of a transfer travel as one RemoteFile. Watching runs off the UI thread at most once a second; a change counts once two runs agree on it, which lets an editor's atomic save settle. Prompts wait for any open dialog instead of replacing it. Saving a copy back to its synced bytes drops its question and closes its dialog, and a failed Re-fetch offers the conflict again. An upload never makes the file readable by anyone who could not read it before. A POSIX host gives the temp file the target's group and mode before the rename, and writes in place instead when the group cannot be kept with the same reach or an ACL shows (`ls` `+` or `@`, `getfacl`). Windows PowerShell 5.1 and 7 create the temp file with the target's DACL. Keep both takes the original file's access. When access cannot be kept and nothing can be written safely, the host exits with its own code and the user is told so. A macOS host's inheritable folder ACL can still read the temp file during the transfer. Each window keeps its copies in its own cache folder, so a fetch in one window never replaces another's unsent edits. A click while its file is still being fetched starts no second fetch, and each fetch writes its own part file.
`herdr-gpui remote-files [list]` reports the copies of SSH hosts' files that link clicks fetched into the window, each with its host, host path, local path, sync state, and whether its saves upload without asking. It is read-only: uploads stay behind the app's dialog. The reply fits the socket's message budget and counts what it omits, as `notes list` does. Document remote file links in the Herdr UI README, the transfer API in the client README, the command in docs/cli.md, and the behaviour beside open_files_in in the example config.
The overview said SSH needs "an installed POSIX Herdr", but a macOS client connects to a Windows OpenSSH host running Herdr 0.9.4, and the Files on SSH Hosts section already describes Windows hosts.
A URL or path that ran to the pane's right edge was never a link: the client gets no soft-wrap information, so the row reader refused any token reaching the last column in case it continued on the next row. The reader now decides. `terminal/links/wrap.rs` walks the hit word's rows and joins them in two cases: a raw terminal wrap (no walls, the word ends at the pane's last column and the next row starts at column 0), and an application's word wrap as Ink/wrap-ansi lays it out (same walls, the next row starting under the text or after a bullet marker, and a word too long for the wrap width). Walls are frame characters that line up on both rows, so boxes, table cells and side-by-side panels each wrap at their own border. The existing URL and path parsers run on the joined text, and the match maps back to one segment per row, which the painter already draws. A word that reaches the edge and does not continue is a complete link. Text that runs to the end of the pane's bottom row, or ends in `…`, is still refused, since it may be cut off. A raw-wrapped URL is left to a daemon that offers `pane.link.resolve`. When the local reading strictly contains the daemon's resolved link, as at an app's word wrap, the local one is underlined and opened if the daemon declines the click. OSC 8 hyperlinks that continue on the next row are underlined on both.
Mock pages are printed as `X:\...\index.html?sheet=1` or as `file:///.../index.html?sheet=1`. The disk check looked for a file literally named `index.html?sheet=1`, so the path was never a link, and `Url::to_file_path` dropped the query from `file://` links. A path containing a separator may now end in `?query` or `#fragment`. `plain_path` splits it off at the first unescaped mark after a separator. Guards keep real names whole: no split at the start of a component, `?` only before `key=` or after an extension, `#` only after an extension and before an anchor, `L<n>[-L<m>]`, or `page=<n>` on a PDF, and never at an empty suffix. The disk check and SSH's shape check see the path part, the underline covers the whole token, and `#L10` sets the line when there is no `:line`. `file://` links keep their query and fragment, and a suffix is capped at 4096 bytes. Clicking a web page (html, htm, xhtml, svg), a PDF, or a file with no extension that has a suffix starts the default browser directly with the `file://` URL, suffix included, because the system openers drop it. This covers local panes and SSH copies (fetched, cached, and kept edits). - Windows reads the https/http UserChoice ProgId (UserChoiceLatest first) and its open command. - macOS asks NSWorkspace for the default browser and runs its bundle executable. Safari takes no URL argument, so it falls back. - Linux has no direct launch and always falls back. The URL is passed as one argument, never through a shell. If the browser cannot start, the click opens what it would open without a suffix, so a file with no extension that is executable opens its folder and is never run. Every other file opens as before, without the suffix.
Local Windows 11 x86_64:
Not run at e4bfacb: WSL (Linux) and macOS. At 34e3244, WSL clippy (all features and no default features) and The merge needed one fit to The |
[Medium impact] Adds remote file transfer and default browser detection. Do not merge until remote uploads cannot follow replacement symlinks, repeat fetches preserve editor saves, and quick clicks open complete wrapped URLs. Findings
SummaryAdds wrapped terminal links, path queries and fragments, direct browser opening, and SSH file copies with confirmed uploads.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Modifier-click terminal link] --> B[Read joined rows and suffix]
B --> C{Daemon handles click?}
C -->|Yes| D[Handler opens link]
C -->|No| E{Target}
E -->|Web URL| F[Open browser]
E -->|Local file| G[Check file and opening rules]
E -->|SSH file| H[Fetch private local copy]
H --> G
G --> I[Open document or containing folder]
G --> J[Start browser with query]
H --> K[Watch editor saves]
K --> L[Ask before uploading]
L --> M[Check remote hash and upload]
Reviews (1) · Last reviewed commit: "feat: open file paths printed with a ?qu..." · Reviewed by Greptile |
| elif [ -e "$path" ]; then | ||
| if [ ! -w "$path" ]; then exit {ACCESS_EXIT}; fi | ||
| cat < "$tmp" > "$path" || exit 1 |
There was a problem hiding this comment.
Uploads can overwrite another file
If another user can change the destination directory on a POSIX host, they can replace the destination with a symlink after its checks. When an ACL forces this in-place branch, both -w and > follow that link. The upload then overwrites another file writable by the SSH account.
Use a write that checks and opens the same file without following symlinks, or refuse this fallback.
How this was verified: The script checks the destination before keep_access, then opens it again through a shell redirection that follows symlinks.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed, and the window was wider than the in-place branch: the rename branch also reopened its temporary file by name after mktemp, and chgrp/chmod followed links. Fixed in 6a31f50 on #627, merged here in 34e3244. The push script now enters the target's folder once with cd -P and names the target relative to it. It builds the copy in a private mktemp -d folder, checked to be the SSH user's drwx------ folder inside the anchored one, with every file in it created by mktemp. It moves the copy over the target with mv -T (GNU) or mv -h (BSD) where mv takes one. It writes in place only in a folder no one else can replace files in (owned by the SSH user or root, no ACL, and not writable by group or others unless sticky with the target the user's or root's). Anywhere else it refuses with its own exit code and error, "Other users can replace files in that folder on the host, so it was not written in place; nothing was changed." Keep both never writes in place. Tests in remote_file/tests/posix_exec.rs swap the target, the temporary file and the work folder for links through tool shims on PATH, and check that the victim file is unchanged.
| let Some(daemon) = self.hovered_daemon_link() else { | ||
| return false; |
There was a problem hiding this comment.
Quick clicks open shortened URLs
A modifier-click before the hover reply arrives can open only the first row of an app-wrapped URL. local_contains_daemon returns false without that reply, and the activation saves that choice. When the daemon declines with its first-row URL, finish_activation chooses it over the complete local reading.
This can happen during the 40 ms hover delay or while waiting for an SSH reply. Prefer the complete app-wrapped reading without requiring a completed hover request, while still letting handlers claim the click.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed and fixed in 738f5b2. With no resolved link yet to compare, local_contains_daemon now lets a local reading that spans more than one row win. On a daemon offering pane.link.resolve, a raw-wrapped address is left to the daemon, so a multi-row local web reading is an application's word wrap that the daemon reads as separate lines. A handler still claims the click first. Test: a_click_before_the_hover_reply_opens_the_whole_wrapped_link.
| source, | ||
| }) | ||
| .inspect_err(|_| remove_part(&part))?; | ||
| std::fs::rename(&part, &local) |
There was a problem hiding this comment.
Repeat fetches can erase saves
fetch_copy can overwrite a save made after edited_since returns but before rename runs. An editor can finish its save in that gap; the rename replaces those edits with the downloaded file, and the returned copy is marked Synced. The watcher cannot recover the overwritten bytes.
Avoid replacing an editable copy based on a separate earlier check. Keep the current copy or fetch into a new copy when another application may be editing it.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed and fixed in 56a5f78 on #627, merged here in 34e3244. When edits are kept, the copy now moves aside first, and the download is hard-linked into its place, which fails rather than replace a save that took the place meanwhile. The copy moved aside comes back over the download unless its metadata proves it is the one last synced. A filesystem without hard links falls back to a rename. The watch run also skips a copy a fetch is writing, and a conflict's refetch now holds its copy in the in-flight set as a click's fetch does. Tests in remote_files/tests/fetch_swap.rs save in place, by temp-and-rename, and through a handle at each step of the swap.
| pub(super) fn split_suffix(token: &str, drive: bool) -> Option<(&str, Option<&str>)> { | ||
| let separator = |c: char| c == '/' || (drive && c == '\\'); | ||
| let mut after_folder = false; | ||
| let mut previous = None; | ||
| let mut chars = token.char_indices(); | ||
| while let Some((index, c)) = chars.next() { | ||
| if c == '\\' && !drive { | ||
| // The escaped character is part of the name. | ||
| previous = chars.next().map(|(_, escaped)| escaped); |
There was a problem hiding this comment.
Queries are not split from relative Windows paths such as docs\index.html?x=1. Without a drive letter, drive is false, so this code treats backslashes as escapes rather than folder separators. In an SSH pane with C:\dev as its working directory, the fetch asks for C:\dev\docs\index.html?x=1 instead of the file without the query.
Pass the host's path style into suffix parsing and cover this relative-path case.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed and fixed in 738f5b2. LinkReading now carries whether the pane's host writes Windows paths. It is judged from the pane's working directory (a drive or UNC path means Windows, a leading / means not), else from this machine. Only the suffix split uses it, so POSIX escapes such as /tmp/a\ b are unchanged. Tests: a_relative_windows_path_splits_its_query_on_a_windows_host and the window-level windows_paths.rs.
| let query = file_suffix(&url); | ||
| if query | ||
| .as_ref() | ||
| .is_some_and(|query| query.len() > path::MAX_SUFFIX_BYTES) | ||
| { | ||
| return RowTarget::Malformed; | ||
| } | ||
| url.to_file_path() | ||
| .ok() | ||
| .and_then(|path| path.into_os_string().into_string().ok()) | ||
| .map_or(RowTarget::Malformed, named) | ||
| .map_or(RowTarget::Malformed, |path| named(path, query)) |
There was a problem hiding this comment.
file:// links keep #L10 but never use it to set line. explicit_target creates every file target with line: None, and plain_address also resets it to None. Thus file:///tmp/main.rs#L10 opens at the default editor position, unlike src/main.rs#L10.
Apply fragment_line to file URLs too, and preserve that line when creating the plain target.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed and fixed in 738f5b2. file://…#L10 links, as plain text or as hyperlinks, and schemeless hyperlinks such as src/main.rs#L10-L20, now take their line from the fragment, as a printed src/main.rs#L10 already did. Test: a_file_address_or_hyperlink_takes_its_line_from_the_fragment.
| pub(crate) fn basename(path: &str) -> &str { | ||
| let name = path.rsplit(['/', '\\']).next().unwrap_or_default(); |
There was a problem hiding this comment.
Remote filenames get shortened
basename treats backslashes as folder separators even for POSIX hosts. Fetching /srv/foo\bar.txt therefore stores the copy as bar.txt, not foo\bar.txt. On Windows, it also skips the intended conversion to foo_bar.txt.
Use the remote shell's separators when choosing the basename, as conflict_path already does.
There was a problem hiding this comment.
🤖 Posted by Claude Code on behalf of @leftos.
Confirmed and fixed in 56a5f78 on #627, merged here in 34e3244. basename now takes the host's RemoteShell and splits at its separators only, so /srv/foo\bar.txt on a POSIX host is cached and shown as foo\bar.txt, and on Windows as foo_bar.txt. Two files never shared a copy, because the copy's folder is keyed by a hash of the full path, but the name shown was wrong.
…ines Three link fixes from review: - A modifier-click on an address an application wrapped across rows, made before the hover's pane.link.resolve answer arrived, opened only the first row: with no resolved link to compare, the daemon's first-row reading won. A local reading that spans more than one row now wins when nothing is resolved yet; a handler still claims the click first. - A relative Windows path such as docs\index.html?x=1 never split off its query, because without a drive a backslash read as an escape. LinkReading now carries whether the pane's host writes Windows paths, judged from the pane's working directory (a drive or UNC path means Windows, a leading / means not), else from this machine. Only the suffix split uses it; POSIX escapes such as /tmp/a\ b are unchanged. - file://...#L10 links, and schemeless hyperlinks such as src/main.rs#L10-L20, now open at the line the fragment names, as a printed src/main.rs#L10 already did.
Two fixes from review of the SSH file copies: - A repeat fetch checked the copy for saves, then renamed the downloaded file over it, so a save landing between the two was overwritten and the copy marked synced. The copy now moves aside first, the download is hard-linked into its place, which fails rather than replace a save that took the place meanwhile, and the copy moved aside comes back unless its metadata proves it is the one last synced. A filesystem without hard links falls back to a rename. The watch run no longer probes a copy a fetch is writing, and a conflict's refetch now holds its copy in the in-flight set as a click's fetch does, so only one writer handles a copy at a time. - A copy's file name was split at both separators for every host, so /srv/foo\bar.txt on a POSIX host was cached and shown as bar.txt. It is now split at the host shell's own separators, and on Windows the backslash becomes an underscore as the local naming rules intend.
The POSIX push script checked the target for a symbolic link, then reopened it by name: for its temporary file, for chgrp and chmod, and for the in-place write it falls back to when a file's ACL cannot be kept. Another user who can rename entries in the folder could swap in a link between the check and the write, so the upload landed in a different file the SSH account can write. The script now: - enters the target's folder once with cd -P and names the target relative to it, so no later step re-resolves the folder's path; - builds the copy in a private mktemp -d folder inside it, checked to be the SSH user's drwx------ folder within the anchored one, with every file in it created by mktemp, so nothing that already exists is opened or replaced, and moves it over the target with mv -T (GNU) or mv -h (BSD) where mv takes one; - writes in place only in a folder no one else can replace files in (owned by the SSH user or root, no ACL, and not writable by group or others unless sticky with the target the user's or root's), and otherwise refuses with a new exit code, shown as "Other users can replace files in that folder on the host, so it was not written in place; nothing was changed."; - never writes in place for Keep both, and re-checks that the new name is still free just before the rename. A push now also needs id, ls and rmdir on the host.
# Conflicts: # crates/herdr-gpui/src/remote_files.rs
A POSIX push renames a copy owned by the SSH user over the target, so a target another account owned becomes the SSH user's. The copy kept the target's mode, set-user-ID bit included: as root, pushing over another account's 4755 file produced a root-owned setuid executable that runs as root for everyone. Keep the set-user-ID bit only when the copy's owner matches the file whose access it keeps; an unreadable owner on either side drops it. Drop the set-group-ID bit when chgrp fails and the copy keeps its own group. Sticky stays: on a regular file it names no owner or group.
# Conflicts: # crates/herdr-gpui/src/cli/control/hosts.rs # crates/herdr-gpui/src/control/client/hosts.rs # crates/herdr-gpui/src/control/protocol/hosts.rs # crates/herdr-gpui/src/control/protocol/hosts/tests.rs # crates/herdr-gpui/src/control/server/hosts.rs # crates/herdr-gpui/src/control/server/hosts/tests.rs # crates/herdr-gpui/src/menu/chrome.rs # crates/herdr-gpui/src/menu/chrome/keys.rs
# Conflicts: # Cargo.toml # crates/herdr-gpui/Cargo.toml
Stacked on #627. Merge #627 first. This branch starts from
feat/remote-file-links, so its first commits here are #627's, and #627's later fixes come in through a merge of that branch. This PR's own commits are 3f3634e, dfdfcd2 and 738f5b2.Summary
Two link fixes in terminal panes.
A link that wraps is one link (
fix: underline a link that wraps onto the next row as one link)│ … │box walls, hanging indents and markers such as●or⎿.pane.link.resolveanswer arrives.plain_urlnow stops at frame characters, so│https://a.b/c│no longer takes in the│.RowLinknow holdsrows: Vec<(u16, Range<u16>)>, the shape the painter already takes.Paths with
?queryor#fragment(feat: open file paths printed with a ?query or #fragment)A printed path such as
X:\…\index.html?sheet=1, or itsfile://form, was never a link: the disk check looked for a file literally namedindex.html?sheet=1, andUrl::to_file_pathdropped the query.The split: a path that contains a folder separator may now end in
?queryor#fragment. The file is looked for without it, and the underline covers the whole token.Real file names stay whole. There is no split:
#notes!.md,#init.el#);ls's?placeholder;\\?\prefix;?splits before akey=query or after an extension.#splits after an extension, before an anchor,L<n>[-L<m>], orpage=<n>on a PDF.Windows paths: a relative path such as
docs\index.html?x=1splits too when the pane's host writes Windows paths, judged from the pane's working directory, else from this machine.Line numbers:
#L10sets the line when there is no:line, in printed paths,file://links and schemeless hyperlinks.file://links keep their query and fragment.Opening: clicking a web page (
html,htm,xhtml,svg), a PDF, or a file with no extension that has a suffix starts the default browser directly with thefile://URL. The system openers drop the query fromfile://URLs. This works locally and for SSH copies: fetched, cached, and kept edits.UserChoiceProgId and its open command.NSWorkspacegives the default browser, and its bundle executable is run. Safari takes no URL argument, so it falls back.The URL is passed as one argument, never through a shell, and the suffix is capped at 4096 bytes.
If the browser cannot start, the click opens what it would open without a suffix, so an executable with no extension opens its folder and is never run. Every other file (
main.rs#L10,notes.md#usage) opens as before, without the suffix.The crate README's Terminal Links section describes both.
Screenshots
Windows, the real native window, dark theme (no colour changes, so no light variant).
Wrapped links, hovered with Ctrl, one hover per frame:
●reply path under a hanging indent.A path with
?sheet=1, and thefile://form beside it:The same path raw-wrapped inside the file name, and inside the query:
QA
Ran:
just cion Windows x64 at 34e3244. Every step passed excepttest-mockup-wait, which fails on every Windows machine until test: run the mockup wait tests on Windows #643 merges; it is the last step.cargo clippy --workspace --all-targetswith--all-featuresand with--no-default-features,-D warnings, both clean.cargo test --workspace, default and--all-features, all passed.just lint-windowsat dfdfcd2: clean.cargo clippy -p herdr-gpui --all-targets --all-features -D warningsclean, plus the link, file-link, remote-path, remote-file and default-browser tests (211 passed). This covers theNSWorkspacecode and the Unix-only executable-fallback test. Not re-run at 34e3244.index.html?sheet=1token, and both wrapped forms across their rows.file:///X:/…/index.html?sheet=1, and on thefile://form with?sheet=1#top; both were read from the started process's command line.?sheet=1, to the server.For a reviewer:
…/index.html?x=1; the page opens with?x=1. With Safari as the default, the page opens without it.…/page.html?x=1, Ctrl-click it; the fetched copy opens in the browser with?x=1.docs\index.html, printdocs\index.html?x=1and Ctrl-click it; the page opens with?x=1.file:///<a source file>#L10and Ctrl-click it; the editor opens at line 10.