Merge pull request #217 from l0ng-ai/feat/remote-path-completion

Complete remote paths over the pane's SSH connection
This commit is contained in:
l0ng-ai
2026-07-27 13:49:16 +08:00
committed by GitHub
3 changed files with 567 additions and 78 deletions
+112 -47
View File
@@ -1117,12 +1117,14 @@ impl DaemonPane {
// really reading them. The proc query runs only when a
// mark actually claims the prompt — about once per prompt.
// See issue #26.
if signals.shell.as_ref().is_some_and(|s| s.at_prompt)
&& foreground_running()
{
if let Some(s) = signals.shell.as_mut() {
if signals.shell.iter().any(|s| s.at_prompt) && foreground_running() {
for s in signals.shell.iter_mut() {
s.at_prompt = false;
}
// Clearing the flag can leave neighbours identical;
// they no longer describe a crossing, so don't spend
// a frame on each.
signals.shell.dedup();
}
// SSH-context + coding-agent detection are process-table
@@ -1822,7 +1824,9 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
st.cwd = Some(cwd);
}
}
if let Some(shell) = signals.shell {
// One frame per entry: the client needs every prompt-boundary crossing the
// chunk carried, not just where it ended up (see [`SniffSignals::shell`]).
for shell in signals.shell {
// Windows: agent identity rides the C mark's command capture — ConPTY
// has no foreground process group for the Unix 0.5 s poll to read an
// argv from. `C;<cmd>` detects, `D` cleared `command` so it applies
@@ -2287,7 +2291,20 @@ struct ShellState {
#[derive(Default)]
struct SniffSignals {
cwd: Option<PathBuf>,
shell: Option<ShellState>,
/// Shell states completed in this chunk, in stream order — one entry per
/// `at_prompt` *transition*, not one per marker: a run of marks on the same
/// side of the prompt boundary folds into its latest state, so the ordinary
/// `D`/`A`/`B` chunk still yields the single entry it always did.
///
/// Only the last state describes "what the shell is doing now", but the
/// client keys its prompt *cycle* off the false→true edge
/// (`terminal::remote::ShellState::cycle` — what releases a Tab handoff, see
/// `TerminalView::editor_handoff`). Collapsing to the last state alone hides
/// that edge whenever a whole command cycle (`C` … `D`) lands in one read —
/// routine over SSH, where a fast command's output arrives in a single
/// packet — and the handed-off prompt would then never come back to tty7's
/// line editor.
shell: Vec<ShellState>,
/// Sentinel agent events completed in this chunk, in stream order — each
/// one is a state-machine step, so unlike cwd/shell they must *all* apply
/// (a `stop` directly after a `notification` still means "done").
@@ -2315,9 +2332,9 @@ impl OscSniffer {
}
}
/// Feed a chunk; return any cwd / shell-state change completed within it. (If a
/// chunk completes several markers, the last cwd / last shell state wins, which
/// is the only state worth reporting.)
/// Feed a chunk; return any cwd / shell-state change completed within it. (If
/// a chunk completes several cwd markers the last one wins; shell states keep
/// every prompt-boundary crossing — see [`SniffSignals::shell`].)
fn feed(&mut self, bytes: &[u8]) -> SniffSignals {
let mut signals = SniffSignals::default();
let shell = &mut self.shell;
@@ -2326,7 +2343,15 @@ impl OscSniffer {
signals.cwd = Some(path);
} else if let Some(rest) = payload.strip_prefix(b"133;") {
if handle_osc133(shell, rest) {
signals.shell = Some(shell.clone());
match signals.shell.last_mut() {
// Still on the same side of the prompt boundary: fold in,
// latest wins (it carries the freshest exit code / command
// capture). Only a crossing earns its own entry.
Some(last) if last.at_prompt == shell.at_prompt => {
*last = shell.clone();
}
_ => signals.shell.push(shell.clone()),
}
}
} else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) {
signals.agent_events.push(event);
@@ -3131,17 +3156,53 @@ mod tests {
fn sniff_osc133_prompt() {
let mut s = OscSniffer::new();
let b = s.feed(b"\x1b]133;B\x07");
assert!(b.shell.as_ref().unwrap().active);
assert!(b.shell.as_ref().unwrap().at_prompt);
assert!(b.shell.last().unwrap().active);
assert!(b.shell.last().unwrap().at_prompt);
let c = s.feed(b"\x1b]133;C\x07");
assert!(!c.shell.as_ref().unwrap().at_prompt);
assert!(!c.shell.last().unwrap().at_prompt);
// D (command finished) means no command is running, so we're back at the
// prompt: at_prompt is true again (it also carries the exit code).
let d = s.feed(b"\x1b]133;D;130\x07");
assert!(d.shell.as_ref().unwrap().at_prompt);
assert_eq!(d.shell.as_ref().unwrap().last_exit_code, Some(130));
assert!(d.shell.last().unwrap().at_prompt);
assert_eq!(d.shell.last().unwrap().last_exit_code, Some(130));
}
/// A whole command cycle inside ONE chunk still reports the prompt-boundary
/// crossing. The client counts `at_prompt` false→true edges to tell a fresh
/// prompt from a same-prompt redraw, and a Tab handoff only returns the line
/// to tty7's editor on that edge (`TerminalView::editor_handoff`). Reporting
/// just the chunk's final state hid the edge whenever `C` … `D` arrived
/// together — the norm over SSH, where a fast command's whole output lands in
/// one read — so one Tab in an ssh pane disabled the local editor for good.
#[test]
fn a_full_command_cycle_in_one_chunk_still_reports_leaving_the_prompt() {
let mut s = OscSniffer::new();
s.feed(b"\x1b]133;A\x07\x1b]133;B\x07"); // sitting at the prompt
// Enter → command → output → done → next prompt, all in one read.
let sig =
s.feed(b"\x1b]133;C;echo%20hi\x07hi\r\n\x1b]133;D;0\x07\x1b]133;A\x07\x1b]133;B\x07");
let states: Vec<bool> = sig.shell.iter().map(|s| s.at_prompt).collect();
assert_eq!(
states,
vec![false, true],
"the chunk must report leaving the prompt and coming back, not just the end state"
);
assert_eq!(sig.shell.last().unwrap().last_exit_code, Some(0));
assert_eq!(sig.shell.last().unwrap().command, None);
}
/// The flip side: marks that stay on one side of the boundary fold into a
/// single state, so the ordinary prompt draw still costs exactly one frame.
#[test]
fn marks_on_the_same_side_of_the_prompt_boundary_fold_into_one_state() {
let mut s = OscSniffer::new();
let sig = s.feed(b"\x1b]133;D;3\x07\x1b]133;A\x07\x1b]133;B\x07");
assert_eq!(sig.shell.len(), 1, "D/A/B is one at-prompt state");
assert!(sig.shell[0].at_prompt);
assert_eq!(sig.shell[0].last_exit_code, Some(3));
}
/// The C mark's command capture (tty7 extension, PowerShell integration) —
@@ -3156,7 +3217,7 @@ mod tests {
// A submitted `claude --help` (space percent-encoded, as the
// PowerShell body emits it).
let c = s.feed(b"\x1b]133;C;claude%20--help\x07");
let shell = c.shell.as_ref().unwrap();
let shell = c.shell.last().unwrap();
assert!(!shell.at_prompt);
assert_eq!(shell.command.as_deref(), Some("claude --help"));
assert_eq!(
@@ -3166,19 +3227,19 @@ mod tests {
// The command finishing (D) clears the capture → the agent clears.
let d = s.feed(b"\x1b]133;D;0\x07");
let shell = d.shell.as_ref().unwrap();
let shell = d.shell.last().unwrap();
assert_eq!(shell.command, None);
assert_eq!(agent_from_shell_mark(shell, &custom), None);
// A non-agent command sets the capture but detects nothing.
let c = s.feed(b"\x1b]133;C;git%20status\x07");
let shell = c.shell.as_ref().unwrap();
let shell = c.shell.last().unwrap();
assert_eq!(shell.command.as_deref(), Some("git status"));
assert_eq!(agent_from_shell_mark(shell, &custom), None);
// A bare `C` (a foreign shell integration) leaves no capture.
let c = s.feed(b"\x1b]133;C\x07");
assert_eq!(c.shell.as_ref().unwrap().command, None);
assert_eq!(c.shell.last().unwrap().command, None);
// A stray A/B mid-command (a nested/remote shell drawing its own
// prompt) must NOT wipe the capture — only D (command finished) does.
@@ -3186,21 +3247,21 @@ mod tests {
// what keeps the agent chip alive while the agent runs.
let _ = s.feed(b"\x1b]133;C;codex\x07");
let a = s.feed(b"\x1b]133;A\x1b]133;B\x07");
assert_eq!(a.shell.as_ref().unwrap().command.as_deref(), Some("codex"));
assert_eq!(a.shell.last().unwrap().command.as_deref(), Some("codex"));
let d = s.feed(b"\x1b]133;D;0\x07");
assert_eq!(d.shell.as_ref().unwrap().command, None);
assert_eq!(d.shell.last().unwrap().command, None);
// A multi-line command arrives %0A-joined (fish re-joins the split
// list with it) and decodes back to real newlines.
let c = s.feed(b"\x1b]133;C;echo%20a%0Aecho%20b\x07");
assert_eq!(
c.shell.as_ref().unwrap().command.as_deref(),
c.shell.last().unwrap().command.as_deref(),
Some("echo a\necho b")
);
// An all-whitespace payload is no capture, like a bare `C`.
let c = s.feed(b"\x1b]133;C;%20%20\x07");
assert_eq!(c.shell.as_ref().unwrap().command, None);
assert_eq!(c.shell.last().unwrap().command, None);
}
/// The Windows apply gate ([`shell_mark_capture_changed`]): detection
@@ -3214,7 +3275,7 @@ mod tests {
// A wrapper launch: capture set, but detection has no answer.
let mut prev = ShellState::default();
let c = s.feed(b"\x1b]133;C;.%5Cdev.ps1\x07").shell.unwrap();
let c = s.feed(b"\x1b]133;C;.%5Cdev.ps1\x07").shell.pop().unwrap();
assert!(shell_mark_capture_changed(&prev, &c));
assert_eq!(
agent_from_shell_mark(&c, &std::collections::HashMap::new()),
@@ -3224,13 +3285,13 @@ mod tests {
// Stray foreign prompt marks mid-command: same capture, no re-apply —
// an agent branded by sentinel events keeps its chip.
let ab = s.feed(b"\x1b]133;A\x1b]133;B\x07").shell.unwrap();
let ab = s.feed(b"\x1b]133;A\x1b]133;B\x07").shell.pop().unwrap();
assert!(!shell_mark_capture_changed(&prev, &ab));
prev = ab;
// The command finishing clears the capture: that change applies (its
// `None` is what clears the chip at the prompt).
let d = s.feed(b"\x1b]133;D;0\x07").shell.unwrap();
let d = s.feed(b"\x1b]133;D;0\x07").shell.pop().unwrap();
assert!(shell_mark_capture_changed(&prev, &d));
}
@@ -3239,13 +3300,13 @@ mod tests {
let mut s = OscSniffer::new();
let sig = s.feed(b"\x1b]133;V;1\x07");
assert!(
sig.shell.is_none(),
sig.shell.is_empty(),
"edit-mode metadata must not bump prompt state or prompt sequence"
);
let b = s.feed(b"\x1b]133;B\x07");
assert!(b.shell.as_ref().unwrap().active);
assert!(b.shell.as_ref().unwrap().at_prompt);
assert!(b.shell.last().unwrap().active);
assert!(b.shell.last().unwrap().at_prompt);
}
/// The foreground-command predicate: only a process group *other* than the
@@ -3274,18 +3335,20 @@ mod tests {
// The remote fish draws its prompt: A (start) then B (input begins).
let mut signals = s.feed(b"\x1b]133;A\x1b]133;B\x07");
assert!(
signals.shell.as_ref().unwrap().at_prompt,
signals.shell.last().unwrap().at_prompt,
"the raw marks read as at-prompt"
);
// The reader consults the foreground gate before reporting. With ssh (a
// different process group) on the PTY, the prompt flag is cleared.
let ssh_running = is_foreground_command(Some(2000), Some(1000));
if signals.shell.as_ref().is_some_and(|st| st.at_prompt) && ssh_running {
signals.shell.as_mut().unwrap().at_prompt = false;
if signals.shell.iter().any(|st| st.at_prompt) && ssh_running {
for st in signals.shell.iter_mut() {
st.at_prompt = false;
}
}
assert!(
!signals.shell.as_ref().unwrap().at_prompt,
!signals.shell.last().unwrap().at_prompt,
"a foreground program's prompt marks must not engage the local editor"
);
@@ -3293,10 +3356,12 @@ mod tests {
// local prompt) keep at_prompt true — the local editor still engages.
let mut local = s.feed(b"\x1b]133;A\x1b]133;B\x07");
let shell_idle = is_foreground_command(Some(1000), Some(1000));
if local.shell.as_ref().is_some_and(|st| st.at_prompt) && shell_idle {
local.shell.as_mut().unwrap().at_prompt = false;
if local.shell.iter().any(|st| st.at_prompt) && shell_idle {
for st in local.shell.iter_mut() {
st.at_prompt = false;
}
}
assert!(local.shell.as_ref().unwrap().at_prompt);
assert!(local.shell.last().unwrap().at_prompt);
}
/// Regression: a well-formed OSC marker directly following an *unterminated*
@@ -3315,7 +3380,7 @@ mod tests {
let mut s = OscSniffer::new();
let sig = s.feed(b"\x1b]133;A\x1b]133;B\x07");
assert!(
sig.shell.as_ref().map(|sh| sh.at_prompt).unwrap_or(false),
sig.shell.last().is_some_and(|sh| sh.at_prompt),
"OSC 133;B after an unterminated 133;A was dropped (no resync on `]`)"
);
@@ -3345,13 +3410,13 @@ mod tests {
let mut s = OscSniffer::new();
// A command was running…
assert!(!s.feed(b"\x1b]133;C\x07").shell.as_ref().unwrap().at_prompt);
assert!(!s.feed(b"\x1b]133;C\x07").shell.last().unwrap().at_prompt);
// …then finishes: D (in its own chunk, before any prompt text) already
// marks us back at the prompt.
let d = s.feed(b"\x1b]133;D;0\x07");
assert!(
d.shell.as_ref().unwrap().at_prompt,
d.shell.last().unwrap().at_prompt,
"D should mark us back at the prompt before the prompt text is drawn"
);
@@ -3363,12 +3428,12 @@ mod tests {
b"\x1b]133;A\x07\x1b]7;file://host/repo/tty7\x07\r\ntty7 git:(main) \xe2\x9e\x9c ",
);
assert!(
chunk.shell.as_ref().unwrap().at_prompt,
chunk.shell.last().unwrap().at_prompt,
"prompt visible but at_prompt=false — the mis-routing window is still open"
);
// The trailing B finally arrives and keeps it true.
assert!(s.feed(b"\x1b]133;B\x07").shell.as_ref().unwrap().at_prompt);
assert!(s.feed(b"\x1b]133;B\x07").shell.last().unwrap().at_prompt);
}
/// `pty_size` never reports a zero dimension (a 0×0 window would make the
@@ -3499,16 +3564,16 @@ mod tests {
let mut s = OscSniffer::new();
// D with no code.
let d = s.feed(b"\x1b]133;D\x07");
assert!(d.shell.as_ref().unwrap().at_prompt);
assert_eq!(d.shell.as_ref().unwrap().last_exit_code, None);
assert!(d.shell.last().unwrap().at_prompt);
assert_eq!(d.shell.last().unwrap().last_exit_code, None);
// D with a non-numeric code stays None.
let d = s.feed(b"\x1b]133;D;oops\x07");
assert_eq!(d.shell.as_ref().unwrap().last_exit_code, None);
assert_eq!(d.shell.last().unwrap().last_exit_code, None);
// A negative exit code parses.
let d = s.feed(b"\x1b]133;D;-1\x07");
assert_eq!(d.shell.as_ref().unwrap().last_exit_code, Some(-1));
assert_eq!(d.shell.last().unwrap().last_exit_code, Some(-1));
}
/// A fresh `PaneState` for the PTY-less state-machine tests.
@@ -4119,12 +4184,12 @@ mod tests {
apply_signals(
&mut st,
SniffSignals {
shell: Some(ShellState {
shell: vec![ShellState {
active: true,
at_prompt: true,
last_exit_code: Some(0),
command: None,
}),
}],
..SniffSignals::default()
},
);
+269 -2
View File
@@ -1,10 +1,15 @@
//! A small, self-contained completion engine for the command editor — tty7's own
//! engine, not the shell's `compsys`.
//!
//! It offers two sources, each candidate carrying the exact char range it
//! It offers three sources, each candidate carrying the exact char range it
//! replaces:
//! - **command** — builtins + `$PATH` executables, in command position;
//! - **path** — files / directories, elsewhere (replace just the word).
//! - **path** — files / directories, elsewhere (replace just the word);
//! - **remote path** — the same, for a pane whose filesystem is on the far
//! end of an SSH connection. The listing itself is a network round-trip the
//! view owns, so this module only splits the word into a request
//! ([`remote_path_request`]) and turns the answer into candidates
//! ([`remote_path_candidates`]) — both pure, both unit-tested.
//!
//! History deliberately does *not* feed the menu:
//! whole-line recall belongs to the inline ghost text (frecency-ranked, cwd
@@ -280,6 +285,150 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
});
}
/// What a path-position Tab in a remote pane needs listed on the *far side*,
/// produced by [`remote_path_request`] and consumed by
/// [`remote_path_candidates`] once the listing comes back.
///
/// Split in two because the listing is a network round-trip: nothing here
/// touches a filesystem, so both halves stay pure and testable while the view
/// owns the async middle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePathRequest {
/// Absolute directory to list on the remote.
pub dir: String,
/// What an entry's name must start with to be offered.
pub prefix: String,
/// The typed text up to and including the last `/`, re-prepended to every
/// candidate so the path the user typed is preserved (as [`complete_path`]
/// does locally).
pub dir_part: String,
/// Char range in the line the candidates replace.
pub word_start: usize,
pub cursor: usize,
/// Drop file entries — the command only takes directories.
pub dirs_only: bool,
}
/// One entry of a remote directory listing, reduced to what completion cares
/// about. Keeps this module free of the daemon's SFTP protocol types; the view
/// converts (and is where "a symlink to a directory counts as a directory"
/// gets decided, since only the protocol knows the link target).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteEntry {
pub name: String,
pub is_dir: bool,
}
/// The remote directory a path-position Tab wants listed, or `None` when the
/// caret isn't somewhere a remote path listing could help.
///
/// `remote_cwd` is the pane's cwd *in the remote's namespace* — the caller must
/// have established that the pane really is remote. Declines:
/// - **command position** (a bare first word): those complete from `$PATH`,
/// and this machine's `$PATH` is the wrong answer for a remote anyway —
/// that's [`complete_command`]'s call, not a filesystem question.
/// - **`~`-prefixed words**: expanding one needs the remote's `$HOME`, which
/// no OSC reports. Declining hands the Tab to the remote shell, which can
/// expand it.
/// - a **relative `remote_cwd`**: nothing to resolve against.
///
/// Separators are `/` only — deliberately not [`std::path::is_separator`],
/// which also accepts `\` on Windows. A Windows host talking to a POSIX remote
/// must not treat a backslash in the *remote's* path as a separator.
pub fn remote_path_request(
line: &str,
cursor: usize,
remote_cwd: &str,
) -> Option<RemotePathRequest> {
if !remote_cwd.starts_with('/') {
return None;
}
let chars: Vec<char> = line.chars().collect();
let cursor = cursor.min(chars.len());
let mut word_start = cursor;
while word_start > 0 && !chars[word_start - 1].is_whitespace() {
word_start -= 1;
}
let word: String = chars[word_start..cursor].iter().collect();
let is_command = chars[..word_start].iter().all(|c| c.is_whitespace());
if is_command && !word.contains('/') {
return None;
}
if word.starts_with('~') {
return None;
}
let (dir_part, prefix) = match word.rfind('/') {
Some(i) => (&word[..=i], &word[i + 1..]),
None => ("", word.as_str()),
};
// An absolute `dir_part` stands alone; anything else resolves against the
// remote cwd. `.`/`..` inside the path are left for the far side to
// resolve — an SFTP server handles them, and we have no remote filesystem
// to normalize against here.
let dir = if dir_part.starts_with('/') {
dir_part.to_string()
} else if dir_part.is_empty() {
remote_cwd.to_string()
} else {
format!("{}/{dir_part}", remote_cwd.trim_end_matches('/'))
};
Some(RemotePathRequest {
dir,
prefix: prefix.to_string(),
dir_part: dir_part.to_string(),
word_start,
cursor,
dirs_only: current_command(&chars, word_start)
.is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str())),
})
}
/// Turn a remote directory listing into candidates for `req`. Mirrors
/// [`complete_path`]'s rules exactly — hidden entries only when the prefix asks
/// for them, `dirs_only` filtering, closeness ordering, the same cap — so a
/// remote Tab behaves like a local one.
///
/// `.` and `..` are dropped: a local `read_dir` never yields them, and offering
/// them here would make the two panes feel different.
pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry]) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
for entry in entries {
let name = entry.name.as_str();
if name == "." || name == ".." {
continue;
}
if name.starts_with('.') && !req.prefix.starts_with('.') {
continue;
}
if !name.starts_with(&req.prefix) {
continue;
}
if req.dirs_only && !entry.is_dir {
continue;
}
out.push(Candidate {
text: format!("{}{name}", req.dir_part),
kind: if entry.is_dir {
CandidateKind::Dir
} else {
CandidateKind::File
},
start: req.word_start,
end: req.cursor,
description: None,
icon: None,
});
if out.len() >= MAX_CANDIDATES {
break;
}
}
sort_candidates_by_closeness(&mut out);
out
}
/// Filesystem path completion. Splits `word` into the directory part (kept
/// verbatim in each candidate so the typed path prefix is preserved) and the
/// final-segment prefix to match in that directory. Ordered by closeness.
@@ -1158,6 +1307,124 @@ mod tests {
}
}
/// The word under the caret, split and resolved against the *remote* cwd.
/// This is the request the pane's SSH connection is asked to list.
#[test]
fn remote_path_request_splits_the_word_and_resolves_against_the_remote_cwd() {
// Bare word: list the cwd itself, nothing to re-prepend.
let r = remote_path_request("cat fi", 6, "/home/me").unwrap();
assert_eq!(
(r.dir.as_str(), r.prefix.as_str(), r.dir_part.as_str()),
("/home/me", "fi", "")
);
assert_eq!((r.word_start, r.cursor), (4, 6));
assert!(!r.dirs_only);
// Relative subdirectory: resolved against the cwd, typed text preserved.
let r = remote_path_request("cat sub/fi", 10, "/home/me").unwrap();
assert_eq!(r.dir, "/home/me/sub/");
assert_eq!((r.prefix.as_str(), r.dir_part.as_str()), ("fi", "sub/"));
// Absolute: stands alone, the cwd is irrelevant.
let r = remote_path_request("cat /etc/pa", 11, "/home/me").unwrap();
assert_eq!(r.dir, "/etc/");
assert_eq!(r.prefix, "pa");
// A trailing separator on the cwd must not double up.
let r = remote_path_request("cat sub/", 8, "/").unwrap();
assert_eq!(r.dir, "/sub/");
// `cd` takes directories only — same rule as the local engine.
assert!(
remote_path_request("cd pro", 6, "/home/me")
.unwrap()
.dirs_only
);
// A backslash is a filename character on a POSIX remote, not a
// separator — even when tty7 itself runs on Windows.
let r = remote_path_request(r"cat a\b", 7, "/home/me").unwrap();
assert_eq!((r.dir.as_str(), r.prefix.as_str()), ("/home/me", r"a\b"));
}
/// Positions where a remote listing is the wrong answer: the caller falls
/// back to the shell handoff for these rather than guessing.
#[test]
fn remote_path_request_declines_where_a_listing_cannot_help() {
// Command position: `$PATH`, not a directory listing.
assert!(remote_path_request("ls", 2, "/home/me").is_none());
assert!(remote_path_request("", 0, "/home/me").is_none());
// ...unless the "command" is itself a path, which is a real listing.
assert!(remote_path_request("./scr", 5, "/home/me").is_some());
// `~` needs the remote's $HOME, which no OSC reports. The remote shell
// can expand it; we can't, so we decline and let it have the Tab.
assert!(remote_path_request("cat ~/pro", 9, "/home/me").is_none());
// No absolute cwd to resolve against (the remote shell hasn't reported
// one yet, or reported something unusable).
assert!(remote_path_request("cat fi", 6, "").is_none());
assert!(remote_path_request("cat fi", 6, "relative/dir").is_none());
}
/// A remote listing becomes candidates under exactly the local rules —
/// hidden entries stay hidden, the typed directory prefix is preserved, and
/// the ordering is the shared closeness sort.
#[test]
fn remote_path_candidates_mirror_the_local_path_rules() {
let entries = |names: &[(&str, bool)]| -> Vec<RemoteEntry> {
names
.iter()
.map(|(n, d)| RemoteEntry {
name: (*n).to_string(),
is_dir: *d,
})
.collect()
};
let all = entries(&[
(".", true),
("..", true),
(".hidden", false),
("src", true),
("setup.py", false),
("s", false),
("other", false),
]);
let texts =
|cands: Vec<Candidate>| -> Vec<String> { cands.into_iter().map(|c| c.text).collect() };
let req = remote_path_request("cat s", 5, "/home/me").unwrap();
let got = texts(remote_path_candidates(&req, &all));
assert_eq!(
got,
vec!["s", "src", "setup.py"],
"prefix-matched, shortest first; `.`/`..`/hidden/non-matching dropped"
);
// The directory kind survives, so the menu can mark it and the insert
// can add the trailing separator.
let cands = remote_path_candidates(&req, &all);
assert!(cands.iter().find(|c| c.text == "src").unwrap().is_dir());
assert!(!cands.iter().find(|c| c.text == "s").unwrap().is_dir());
// The typed directory part is re-prepended to every candidate, and the
// replacement range covers the whole word.
let req = remote_path_request("cat sub/s", 9, "/home/me").unwrap();
let c = &remote_path_candidates(&req, &all)[0];
assert_eq!(c.text, "sub/s");
assert_eq!((c.start, c.end), (4, 9));
// A dot prefix opts into hidden entries, as it does locally.
let req = remote_path_request("cat .h", 6, "/home/me").unwrap();
let got = texts(remote_path_candidates(&req, &all));
assert_eq!(got, vec![".hidden"]);
// `cd` drops the files.
let req = remote_path_request("cd s", 4, "/home/me").unwrap();
let got = texts(remote_path_candidates(&req, &all));
assert_eq!(got, vec!["src"]);
}
#[test]
fn no_candidates_returns_none() {
let dir = temp_tree("empty", &[("zzz", false)]);
+186 -29
View File
@@ -348,6 +348,11 @@ pub struct TerminalView {
/// when it opened. Typing/Backspace re-filter it in place; it closes on
/// accept, on Escape, or once the edited word no longer matches anything.
completion: Option<CompletionSession>,
/// Whether a remote directory listing for completion is on the wire (see
/// [`Self::spawn_remote_path_completion`]). Holding Tab down would
/// otherwise dial the daemon once per repeat while the first answer is
/// still travelling.
remote_completion_inflight: bool,
/// Monotonic tag bumped every time a completion session opens or closes.
/// Dynamic generators run on background threads and land their results here
/// via `cx.spawn`; each task captures the generation it was spawned under and
@@ -1171,6 +1176,7 @@ impl TerminalView {
completion: None,
completion_generation: 0,
editor_handoff: None,
remote_completion_inflight: false,
reverse_search: None,
integration_notice: None,
integration_notice_shown: false,
@@ -3794,6 +3800,12 @@ impl TerminalView {
let line = self.cmd.text();
let cursor = self.cmd.cursor();
let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else {
// Nothing *locally*. A native-SSH pane can still answer for the
// remote filesystem over its own connection — ask before giving up
// the line (see `spawn_remote_path_completion`).
if self.spawn_remote_path_completion(&line, cursor, forward, cx) {
return;
}
// Nothing to offer. Don't swallow the keypress (#136) — hand the
// line to the shell and let its completion have the Tab.
self.handoff_tab_to_shell(!forward, cx);
@@ -3807,15 +3819,6 @@ impl TerminalView {
// classic behavior byte-for-byte.
let has_pending = !comp.pending.is_empty();
if !has_pending && comp.candidates.len() == 1 {
// Unique match: accept it outright.
let c = comp.candidates[0].clone();
self.completion_insert(&c, c.start);
self.cursor_visible = true;
cx.notify();
return;
}
// The word range is carried by any candidate; with none (pure-generator
// slot) derive it from the caret so the session still knows what it
// replaces.
@@ -3823,26 +3826,18 @@ impl TerminalView {
Some(c) => (c.start, c.end),
None => (word_start_of(&line, cursor), cursor),
};
let word: String = line
.chars()
.skip(word_start)
.take(word_end - word_start)
.collect();
let s = CompletionSession::new(word_start, word.clone(), comp.candidates);
if !has_pending
&& let Some(lcp) = s.common_prefix()
&& lcp.chars().count() > word.chars().count()
{
// Static-only: fill the longest common prefix when it extends the
// typed word. All candidates share it, so the fill never invalidates
// the set. With generators pending we skip this — the eventual set
// may share a shorter prefix, and mutating the line before results
// arrive would be jarring.
self.apply_candidate(&line, word_start, word_end, &lcp);
}
let generation = self.open_completion(s);
self.cursor_visible = true;
cx.notify();
let Some(generation) = self.offer_candidates(
&line,
word_start,
word_end,
comp.candidates,
has_pending,
cx,
) else {
// A unique match was accepted outright; nothing is open to merge into,
// and a static-only slot has no generators anyway.
return;
};
// Kick off each generator on the background executor and merge results
// back on the main thread, tagged with this session's generation.
@@ -3868,6 +3863,168 @@ impl TerminalView {
}
}
/// Put `cands` in front of the user: accept a unique match outright, else
/// open the menu over them (filling the longest common prefix first).
/// Returns the opened session's generation, or `None` when a unique match
/// was accepted and no menu exists.
///
/// `has_pending` means more candidates are still inbound, which disables
/// both shortcuts: a result landing a moment later could add to or change
/// the pick, and mutating the line before then would be jarring.
fn offer_candidates(
&mut self,
line: &str,
word_start: usize,
word_end: usize,
cands: Vec<completion::Candidate>,
has_pending: bool,
cx: &mut Context<Self>,
) -> Option<u64> {
if !has_pending && cands.len() == 1 {
let c = cands[0].clone();
self.completion_insert(&c, c.start);
self.cursor_visible = true;
cx.notify();
return None;
}
let word: String = line
.chars()
.skip(word_start)
.take(word_end - word_start)
.collect();
let s = CompletionSession::new(word_start, word.clone(), cands);
if !has_pending
&& let Some(lcp) = s.common_prefix()
&& lcp.chars().count() > word.chars().count()
{
// Fill the longest common prefix when it extends the typed word.
// All candidates share it, so the fill never invalidates the set.
self.apply_candidate(line, word_start, word_end, &lcp);
}
let generation = self.open_completion(s);
self.cursor_visible = true;
cx.notify();
Some(generation)
}
/// The pane's cwd as a path on the *remote* — `Some` only for a native-SSH
/// pane whose remote shell has reported an absolute cwd (OSC 7). Those are
/// the panes tty7 itself dialled, so the daemon holds an authenticated
/// connection we can ask about that filesystem; every other pane kind
/// (a foreground `ssh`, WSL, plain local) answers `None`.
fn remote_ssh_cwd(&self) -> Option<String> {
let remote = self.terminal.remote_context()?;
if remote.kind != crate::daemon::protocol::RemoteKind::NativeSsh {
return None;
}
let cwd = self.cwd()?.to_string_lossy().into_owned();
cwd.starts_with('/').then_some(cwd)
}
/// Complete a path against the *remote* filesystem, over the pane's own SSH
/// connection. Returns whether a request went out — the caller then leaves
/// the Tab to us instead of handing the line to the shell.
///
/// The listing is a daemon round-trip (`SftpList` on the pane's existing
/// authenticated connection — the same channel the SFTP panel browses
/// with), so it cannot answer this keystroke synchronously. Results land on
/// the main thread and only *then* behave as a local Tab would; see
/// [`Self::remote_path_results`] for what happens to a stale or empty one.
///
/// Out-of-band deliberately. The other way to read a remote directory is to
/// inject a listing command into the live shell and scrape it back out of
/// the PTY — the only option for a terminal that merely *bootstrapped into*
/// a session someone else dialled. tty7 opened this connection itself, so it
/// can just ask: nothing is echoed into the scrollback, no prompt hooks need
/// suppressing, and there's no stray background process to cancel when the
/// user hits Enter. The in-band route stays the answer for the pane kinds
/// that have no tty7-owned connection (a foreground `ssh`, WSL), which is
/// why those decline in [`Self::remote_ssh_cwd`] rather than pretend.
fn spawn_remote_path_completion(
&mut self,
line: &str,
cursor: usize,
forward: bool,
cx: &mut Context<Self>,
) -> bool {
let Some(cwd) = self.remote_ssh_cwd() else {
return false;
};
let Some(req) = completion::remote_path_request(line, cursor, &cwd) else {
return false;
};
// A listing for this same keystroke is already on the wire. Swallow the
// repeat rather than dialling again — the answer is about to arrive and
// will open the menu.
if self.remote_completion_inflight {
return true;
}
self.remote_completion_inflight = true;
let pane_id = self.pane_id;
let dir = req.dir.clone();
let line = line.to_string();
cx.spawn(async move |this, cx| {
let listed = cx
.background_spawn(async move { RemoteTerminal::sftp_list(pane_id, &dir) })
.await;
let _ = this.update(cx, |view, cx| {
view.remote_completion_inflight = false;
view.remote_path_results(
req,
&line,
cursor,
listed.unwrap_or_default(),
forward,
cx,
)
});
})
.detach();
true
}
/// Land a remote directory listing as completion candidates.
///
/// Three outcomes, in the order they're checked:
/// - **the line moved on** while the network answered: drop it. The
/// answer describes a word the user is no longer typing, and the Tab
/// that asked for it is long past.
/// - **nothing matched**: fall back to the shell handoff, exactly as a
/// local no-match does (#136). A directory we couldn't read, or a
/// prefix with no entries, is then no worse off than before this
/// existed — the remote's own completion still gets its shot.
/// - **candidates**: offer them like any local Tab.
fn remote_path_results(
&mut self,
req: completion::RemotePathRequest,
line: &str,
cursor: usize,
listed: Vec<crate::daemon::protocol::SftpEntry>,
forward: bool,
cx: &mut Context<Self>,
) {
if self.cmd.text() != line || self.cmd.cursor() != cursor {
return;
}
let entries: Vec<completion::RemoteEntry> = listed
.into_iter()
.map(|e| completion::RemoteEntry {
// Follow symlinks when classifying, as the local path engine
// does: a link to a directory takes the trailing `/` and
// survives a dirs-only filter (`cd` into one is routine). The
// daemon resolved the target for us.
is_dir: e.kind == crate::daemon::protocol::SftpEntryKind::Dir || e.target_is_dir,
name: e.name,
})
.collect();
let cands = completion::remote_path_candidates(&req, &entries);
if cands.is_empty() {
self.handoff_tab_to_shell(!forward, cx);
return;
}
self.offer_candidates(line, req.word_start, req.cursor, cands, false, cx);
}
/// Open a completion menu and bump the generation tag, returning it so a
/// caller spawning generators can stamp their in-flight results. Every open
/// gets a fresh generation, so a slow generator from a prior session can't be