Skip to main content

strop_engine/editor/remote_completion/
mod.rs

1//! One owned path-completion session for local, SSH and attached containers.
2//! Local/container listings run on workers. SSH paths use only existing live
3//! connections or bounded cached observations; Tab never authenticates.
4//!
5//! Every reply captures the exact prompt, focus, document revision and cursor.
6//! Cycling uses the existing PendingEvent::CompleteEx reducer. Candidates use
7//! lossless resource URIs so native filename bytes cannot become display aliases.
8
9use std::collections::VecDeque;
10use std::path::PathBuf;
11use std::sync::mpsc::{self, Receiver, Sender};
12
13use strop_core::id::{BufferRevision, DocumentId};
14use strop_core::worker::{self, CancelReason, Completion, FailureKind, Outcome, Ticket};
15use strop_remote::{HostCandidate, HostSources, RemoteClient, RemoteEntryKind};
16use strop_workspace::RemoteFile;
17
18use super::document::DocumentSource;
19use super::pending::{PendingEvent, PromptContext};
20use super::Editor;
21
22mod directory;
23#[cfg(test)]
24mod tests;
25
26/// Bounded fallback cache: successful live listings remembered so a
27/// later offline Tab still completes from the last observed truth.
28const CACHE_DIRS: usize = 32;
29
30/// Commands whose final argument is a remote URI, and how many numeric
31/// arguments may precede it (`(min, max)`). `w`/`wq` refuse remote
32/// targets outright (execution refuses them too), so they are absent
33/// here and answered with an honest message instead.
34fn remote_operand_shape(command: &str) -> Option<(usize, usize)> {
35    match command {
36        "e" | "e!" | "view" | "sp" | "split" | "vs" | "vsplit" | "browse" | "follow" => {
37            Some((0, 0))
38        }
39        // `:tail [BYTES] URI` — the byte count is optional.
40        "tail" => Some((0, 1)),
41        // `:range START BYTES URI` — both numbers required.
42        "range" => Some((2, 2)),
43        _ => None,
44    }
45}
46
47/// Which local question a completion asked. Pure serde data — the
48/// replayable half of the exchange; worker inputs (sources, history,
49/// the client) are live values and never serialize.
50#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum RemoteCompletionQuery {
52    /// Complete the endpoint token typed after `ssh://` (no `/` yet).
53    Hosts { partial: String },
54    /// Complete the final path segment of `ssh://<authority>/dir/seg…`
55    /// against a live read-only connection. `directory` is URI text
56    /// with a leading `/` (the canonical listing target is derived and
57    /// validated through `RemoteFile::parse`).
58    Path {
59        authority: String,
60        directory: String,
61        segment: String,
62    },
63    Directory {
64        location: strop_workspace::ResourceLocation,
65        segment: Vec<u8>,
66        container: Option<strop_containers::ContainerIdentity>,
67    },
68}
69
70impl RemoteCompletionQuery {
71    fn label(&self) -> &'static str {
72        match self {
73            Self::Hosts { .. } => "hosts",
74            Self::Path { .. } => "path",
75            Self::Directory { .. } => "directory",
76        }
77    }
78}
79
80/// One completion answer item: the canonical URI text that replaces
81/// the typed `ssh://…` token. Directories carry a trailing `/` so the
82/// next Tab descends into them; files are complete URIs.
83#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct RemoteCandidate {
85    pub uri: String,
86    pub directory: bool,
87}
88
89/// Where candidates came from — surfaced so cache is never mistaken
90/// for live state.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub enum CandidateSource {
93    Config,
94    Connection,
95    Cache,
96    Directory,
97}
98
99/// The typed moment a request owns; every delivery re-checks it.
100#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101pub struct RemoteCompletionKey {
102    pub focus: u64,
103    pub document: DocumentId,
104    pub revision: BufferRevision,
105    /// Full prompt text (sigil included) at request time.
106    pub text: String,
107    pub cursor: usize,
108    pub query: RemoteCompletionQuery,
109    pub prefix_body: String,
110}
111
112/// The worker's terminal answer. Failures travel as
113/// `Outcome::Failed`; this carries only successes.
114#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
115pub enum RemoteCompletionResult {
116    Candidates {
117        items: Vec<RemoteCandidate>,
118        source: CandidateSource,
119        /// Enumeration diagnostics (bounded) shown when nothing matched.
120        notes: Vec<String>,
121        /// Canonical URI of the listed directory, for the fallback
122        /// cache. None for host completions.
123        listed_directory: Option<String>,
124    },
125    /// Path completion found no live connection. Connecting is an
126    /// explicit user action (open/browse); completion must not take it.
127    ConnectRequired { endpoint: String },
128}
129
130/// One worker delivery, ticket-stamped like every other service.
131pub type RemoteCompletionEvent = Completion<RemoteCompletionKey, RemoteCompletionResult>;
132
133/// Landed candidates plus the moment they were applied to — the cycle
134/// state between Tabs.
135#[derive(Debug, Clone)]
136struct ReadyCompletion {
137    /// Prompt body before the `ssh://` token (e.g. `e ` or `tail 64k `).
138    prefix_body: String,
139    /// Full prompt text after our last apply; a mismatch means the
140    /// user typed since, and Tab starts a fresh request instead.
141    applied: String,
142    candidates: Vec<RemoteCandidate>,
143    index: usize,
144}
145
146/// The editor's remote-completion slot: one in-flight request, one
147/// landed cycle, one bounded fallback cache, one delivery channel.
148#[derive(Debug)]
149pub(crate) struct RemoteCompletionState {
150    /// Worker deliveries; the TUI forwards this onto the app channel
151    /// (Main wires `AppEvent::RemoteCompletion`), headless drains it.
152    pub tx: Sender<RemoteCompletionEvent>,
153    pub rx: Option<Receiver<RemoteCompletionEvent>>,
154    pub(crate) pending: Option<Ticket<RemoteCompletionKey>>,
155    ready: Option<ReadyCompletion>,
156    cache: VecDeque<(String, Vec<RemoteCandidate>)>,
157}
158
159impl Default for RemoteCompletionState {
160    fn default() -> Self {
161        let (tx, rx) = mpsc::channel();
162        Self {
163            tx,
164            rx: Some(rx),
165            pending: None,
166            ready: None,
167            cache: VecDeque::new(),
168        }
169    }
170}
171
172impl RemoteCompletionState {
173    fn cached(&self, canonical_dir: &str) -> Option<Vec<RemoteCandidate>> {
174        self.cache
175            .iter()
176            .rev()
177            .find(|(key, _)| key == canonical_dir)
178            .map(|(_, items)| items.clone())
179    }
180
181    fn store_cache(&mut self, canonical_dir: String, items: Vec<RemoteCandidate>) {
182        if items.is_empty() {
183            return;
184        }
185        self.cache.retain(|(key, _)| key != &canonical_dir);
186        self.cache.push_back((canonical_dir, items));
187        while self.cache.len() > CACHE_DIRS {
188            self.cache.pop_front();
189        }
190    }
191
192    #[cfg(test)]
193    fn ticket(&self) -> Option<Ticket<RemoteCompletionKey>> {
194        self.pending.clone()
195    }
196}
197
198impl Editor {
199    fn revoke_path_completion(&mut self) {
200        if let Some(old) = self.remote_completion.pending.take() {
201            if let Some(handle) = self.worker_handles.remove(&old.request) {
202                handle.cancel(CancelReason::Superseded);
203            }
204        }
205        self.remote_completion.ready = None;
206    }
207
208    pub(crate) fn invalidate_filesystem_completions(&mut self) {
209        self.revoke_path_completion();
210        self.remote_completion.cache.clear();
211    }
212
213    /// Tab on the ex line's remote operand: cycle landed candidates or
214    /// start a request. Returns true when the line was a remote
215    /// completion (even when the answer is a refusal message), so the
216    /// caller's command-name cycling never touches a remote line.
217    pub(crate) fn remote_completion_tab(&mut self) -> bool {
218        let Some((text, cursor)) = self
219            .pending
220            .prompt()
221            .map(|prompt| (prompt.text().to_owned(), prompt.cursor()))
222        else {
223            return false;
224        };
225        let Some(body) = text.strip_prefix(':') else {
226            return false;
227        };
228        let Some((cmd, rest)) = body.split_once(' ') else {
229            return false;
230        };
231        let filesystem = cmd == "fs";
232        let (cmd, rest) = if filesystem {
233            let Some((operation, destination)) = rest.split_once(' ') else {
234                return false;
235            };
236            if !matches!(operation, "create" | "mkdir" | "rename" | "move" | "copy") {
237                return false;
238            }
239            let destination = if operation == "copy" {
240                destination
241                    .strip_prefix("stored ")
242                    .or_else(|| destination.strip_prefix("buffer "))
243                    .unwrap_or(destination)
244            } else {
245                destination
246            };
247            (operation, destination)
248        } else {
249            (cmd, rest)
250        };
251        let tokens = rest.split(' ').filter(|token| !token.is_empty());
252        let remote = rest.starts_with("ssh://")
253            || (matches!(cmd, "tail" | "range")
254                && tokens.clone().any(|token| token.starts_with("ssh://")));
255        let (query, prefix_body) = if remote {
256            let Some(operand) = tokens
257                .clone()
258                .next_back()
259                .filter(|operand| operand.starts_with("ssh://"))
260            else {
261                self.message = "remote URI cannot contain a raw space (type %20)".into();
262                return true;
263            };
264            if matches!(cmd, "w" | "w!" | "wq" | "wq!") {
265                self.message = "remote save-as completion is unsupported".into();
266                return true;
267            }
268            let Some((min, max)) = (if filesystem {
269                Some((0, 0))
270            } else {
271                remote_operand_shape(cmd)
272            }) else {
273                return false;
274            };
275            let leading = tokens.count().saturating_sub(1);
276            if leading < min || leading > max {
277                self.message = match cmd {
278                    "range" => ":range needs START BYTES before the URI".into(),
279                    "tail" => ":tail takes at most one byte count before the URI".into(),
280                    _ => format!(":{cmd} takes no argument before the URI"),
281                };
282                return true;
283            }
284            let prefix = body[..body.len() - operand.len()].to_owned();
285            (
286                classify_remote_operand(operand.strip_prefix("ssh://").unwrap_or_default()),
287                prefix,
288            )
289        } else {
290            if !filesystem
291                && !matches!(
292                    cmd,
293                    "e" | "e!" | "view" | "sp" | "split" | "vs" | "vsplit" | "browse"
294                )
295            {
296                return false;
297            }
298            let context = if filesystem {
299                match self.filesystem_completion_context(cmd, rest) {
300                    Ok(context) => context,
301                    Err(error) => {
302                        self.message = error;
303                        return true;
304                    }
305                }
306            } else {
307                self.open_context()
308            };
309            (
310                directory::classify(self, rest, context),
311                body[..body.len() - rest.len()].to_owned(),
312            )
313        };
314        if cursor != text.len() {
315            self.message = "completion needs the cursor at the end of the line".into();
316            return true;
317        }
318        if let Some(ready) = self.remote_completion.ready.as_ref() {
319            if self.pending.text() == ready.applied && ready.candidates.len() > 1 {
320                let next = (ready.index + 1) % ready.candidates.len();
321                let uri = ready.candidates[next].uri.clone();
322                let prefix = ready.prefix_body.clone();
323                self.apply_completion(&prefix, &uri);
324                if let Some(ready) = self.remote_completion.ready.as_mut() {
325                    ready.index = next;
326                    ready.applied = self.pending.text().to_owned();
327                }
328                return true;
329            }
330        }
331        match query {
332            Ok(query) => self.start_remote_completion(query, prefix_body),
333            Err(error) => self.message = error,
334        }
335        true
336    }
337
338    /// Classify the typed operand and launch the owned worker request.
339    fn start_remote_completion(&mut self, query: RemoteCompletionQuery, prefix_body: String) {
340        let Some((text, cursor, document, revision)) =
341            self.pending
342                .prompt()
343                .and_then(|prompt| match prompt.context() {
344                    PromptContext::Ex(origin) => Some((
345                        prompt.text().to_owned(),
346                        prompt.cursor(),
347                        origin.pane.doc,
348                        origin.revision,
349                    )),
350                    _ => None,
351                })
352        else {
353            return;
354        };
355        // A path query needs the canonical listing target up front:
356        // admission happens once, through the address grammar. The
357        // canonical directory URI is also the fallback-cache key.
358        let (dir_file, fallback) = match &query {
359            RemoteCompletionQuery::Path {
360                authority,
361                directory,
362                ..
363            } => match RemoteFile::parse(&format!("ssh://{authority}{directory}")) {
364                Ok(file) => {
365                    let fallback = self.remote_completion.cached(&file.to_string());
366                    (Some(file), fallback)
367                }
368                Err(error) => {
369                    self.message = format!("invalid remote address: {error}");
370                    return;
371                }
372            },
373            RemoteCompletionQuery::Hosts { .. } | RemoteCompletionQuery::Directory { .. } => {
374                (None, None)
375            }
376        };
377        // A new request replaces any in-flight one; the old worker's
378        // late delivery is rejected by ticket mismatch.
379        self.revoke_path_completion();
380        let key = RemoteCompletionKey {
381            focus: self.focus_epoch,
382            document,
383            revision,
384            text,
385            cursor,
386            query: query.clone(),
387            prefix_body,
388        };
389        let request = match self.worker_ids.allocate() {
390            Ok(request) => request,
391            Err(error) => {
392                self.message = error.message;
393                return;
394            }
395        };
396        let ticket = Ticket {
397            request,
398            key: key.clone(),
399        };
400        self.remote_completion.pending = Some(ticket.clone());
401        self.message = "completing…".into();
402        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
403            serde_json::json!({
404                "service":"remote-completion","request":request.get(),
405                "query":query.label(),
406            })
407        });
408        match self.tape.request("remote.completion", &ticket) {
409            Ok(false) => return,
410            Ok(true) => {}
411            Err(error) => {
412                self.handle_remote_completion(Completion {
413                    ticket,
414                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
415                });
416                return;
417            }
418        }
419        let sources = completion_host_sources();
420        let history = self.remote_history();
421        let client = self.remote_client();
422        let tx = self.remote_completion.tx.clone();
423        let handle = worker::spawn(
424            "strop-remote-complete",
425            move |outcome| {
426                let _ = tx.send(Completion { ticket, outcome });
427            },
428            move |cancel| {
429                run_completion(query, dir_file, fallback, sources, history, client, cancel)
430            },
431        );
432        self.worker_handles.insert(request, handle);
433    }
434
435    /// One worker delivery: only the owning ticket may touch the
436    /// model, and only a still-fresh prompt moment may be rewritten.
437    pub(crate) fn handle_remote_completion(&mut self, event: RemoteCompletionEvent) {
438        if self.remote_completion.pending.as_ref() != Some(&event.ticket) {
439            strop_trace::record_with(
440                strop_trace::EventKind::JobRejected,
441                || serde_json::json!({"service":"remote-completion","reason":"superseded"}),
442            );
443            return;
444        }
445        let ticket = event.ticket;
446        self.remote_completion.pending = None;
447        self.worker_handles.remove(&ticket.request);
448        if !self.completion_prompt_fresh(&ticket.key) {
449            strop_trace::record_with(
450                strop_trace::EventKind::JobRejected,
451                || serde_json::json!({"service":"remote-completion","reason":"stale prompt"}),
452            );
453            return;
454        }
455        match event.outcome {
456            Outcome::Success(RemoteCompletionResult::Candidates {
457                items,
458                source,
459                notes,
460                listed_directory,
461            }) => {
462                if let Some(directory) = &listed_directory {
463                    self.remote_completion
464                        .store_cache(directory.clone(), items.clone());
465                }
466                if items.is_empty() {
467                    self.message = match notes.first() {
468                        Some(note) => format!("no path matches: {note}"),
469                        None => "no path matches".into(),
470                    };
471                    return;
472                }
473                let prefix_body = ticket.key.prefix_body.clone();
474                self.apply_completion(&prefix_body, &items[0].uri);
475                self.message = candidates_message(&items, source);
476                self.remote_completion.ready = Some(ReadyCompletion {
477                    prefix_body,
478                    applied: self.pending.text().to_owned(),
479                    candidates: items,
480                    index: 0,
481                });
482                if let Some(note) = notes.first() {
483                    self.message.push_str(" — ");
484                    self.message.push_str(note);
485                }
486            }
487            Outcome::Success(RemoteCompletionResult::ConnectRequired { endpoint }) => {
488                self.message = format!(
489                    "no live connection to {endpoint}; completion never connects \
490                     — open or browse the remote first"
491                );
492            }
493            Outcome::Failed { failure, .. } => self.message = failure.message,
494            Outcome::Cancelled(_) => {}
495        }
496    }
497
498    /// The request's prompt moment still describes the editor: same
499    /// ex prompt, focus, document, revision, input text and cursor.
500    fn completion_prompt_fresh(&self, key: &RemoteCompletionKey) -> bool {
501        let Some(prompt) = self.pending.prompt() else {
502            return false;
503        };
504        matches!(prompt.context(), PromptContext::Ex(_))
505            && !self.docs.is_empty()
506            && self.current() == key.document
507            && self.focus_epoch == key.focus
508            && self.buf().revision() == key.revision
509            && prompt.text() == key.text
510            && prompt.cursor() == key.cursor
511            && match &key.query {
512                RemoteCompletionQuery::Directory {
513                    location,
514                    container: Some(expected),
515                    ..
516                } => matches!(&location.filesystem, strop_workspace::Filesystem::Container(id)
517                        if self.containers.attached.get(id.as_str()) == Some(expected)),
518                _ => true,
519            }
520    }
521
522    /// Apply one candidate through the one prompt grammar.
523    fn apply_completion(&mut self, prefix_body: &str, uri: &str) {
524        self.feed_pending_event(PendingEvent::CompleteEx(format!("{prefix_body}{uri}")));
525    }
526
527    /// Host candidates from endpoints this editor already opened.
528    fn remote_history(&self) -> Vec<HostCandidate> {
529        self.docs
530            .iter()
531            .filter_map(|(_, document)| match &document.source {
532                DocumentSource::Remote(file) => {
533                    let endpoint = file.file.endpoint();
534                    Some(HostCandidate::new(
535                        endpoint.host().to_owned(),
536                        endpoint.user().map(str::to_owned),
537                        endpoint.port(),
538                        strop_remote::CandidateOrigin::History,
539                    ))
540                }
541                _ => None,
542            })
543            .collect()
544    }
545}
546
547/// Sort, show and label the candidate list for the message line.
548fn candidates_message(items: &[RemoteCandidate], source: CandidateSource) -> String {
549    let mut text = items
550        .iter()
551        .take(6)
552        .map(display_segment)
553        .collect::<Vec<_>>()
554        .join("  ");
555    if items.len() > 6 {
556        text.push_str(&format!("  (+{})", items.len() - 6));
557    }
558    match source {
559        CandidateSource::Cache => text.push_str("  (cached)"),
560        CandidateSource::Connection => text.push_str("  (live)"),
561        CandidateSource::Config => {}
562        CandidateSource::Directory => {}
563    }
564    if text.len() > 160 {
565        let mut boundary = 160;
566        while !text.is_char_boundary(boundary) {
567            boundary -= 1;
568        }
569        text.truncate(boundary);
570    }
571    text
572}
573
574/// Safe display of one candidate: the final URI segment (directories
575/// keep their trailing `/`). Escaped form — never a lossy decode of
576/// native bytes.
577fn display_segment(candidate: &RemoteCandidate) -> &str {
578    let uri = &candidate.uri;
579    let cut = match uri.rfind('/') {
580        Some(at) if at + 1 == uri.len() => uri[..at].rfind('/').map_or(at, |prev| prev + 1),
581        Some(at) => at + 1,
582        None => return uri.strip_prefix("ssh://").unwrap_or(uri),
583    };
584    &uri[cut..]
585}
586
587/// Split the typed operand into a query, or an honest refusal. `~`
588/// entries are unresolved home queries (RemoteLocation's domain): they
589/// need a negotiated connection, so completion refuses instead of
590/// guessing a home.
591fn classify_remote_operand(typed: &str) -> Result<RemoteCompletionQuery, String> {
592    let refuse_home =
593        || "cannot complete `~` paths: open the remote file so its home resolves first".to_string();
594    if typed.starts_with('~') {
595        return Err(refuse_home());
596    }
597    let Some((authority, path)) = typed.split_once('/') else {
598        return Ok(RemoteCompletionQuery::Hosts {
599            partial: typed.to_owned(),
600        });
601    };
602    if authority.is_empty() {
603        return Err("ssh:// needs a host before the path".to_string());
604    }
605    if path.split('/').next() == Some("~") {
606        return Err(refuse_home());
607    }
608    let (directory, segment) = match path.rsplit_once('/') {
609        Some((before, last)) => (format!("/{before}"), last.to_owned()),
610        None => ("/".to_owned(), path.to_owned()),
611    };
612    Ok(RemoteCompletionQuery::Path {
613        authority: authority.to_owned(),
614        directory,
615        segment,
616    })
617}
618
619/// Standard local host-data locations for this process's home. Only
620/// path names are resolved here — reading happens on the worker.
621fn completion_host_sources() -> HostSources {
622    let home = std::env::var_os("HOME").map(PathBuf::from);
623    HostSources::discover(home.as_deref())
624}
625
626/// The worker side of one completion request. Host completion reads
627/// local data; path completion uses `list_connected` — never a new
628/// connection — and falls back to the caller's cached listing when the
629/// endpoint is not connected.
630fn run_completion(
631    query: RemoteCompletionQuery,
632    directory: Option<RemoteFile>,
633    fallback: Option<Vec<RemoteCandidate>>,
634    sources: HostSources,
635    history: Vec<HostCandidate>,
636    client: RemoteClient,
637    cancel: worker::CancelToken,
638) -> Outcome<RemoteCompletionResult> {
639    if cancel.is_cancelled() {
640        return Outcome::Cancelled(CancelReason::OwnerClosed);
641    }
642    match query {
643        RemoteCompletionQuery::Directory {
644            location,
645            segment,
646            container,
647        } => directory::run(location, &segment, container.as_ref(), &client, &cancel),
648        RemoteCompletionQuery::Hosts { partial } => {
649            let enumeration = strop_remote::enumerate_hosts(&sources, &history);
650            let items = enumeration
651                .complete(&partial)
652                .into_iter()
653                .map(|token| RemoteCandidate {
654                    uri: format!("ssh://{token}"),
655                    directory: false,
656                })
657                .collect();
658            Outcome::Success(RemoteCompletionResult::Candidates {
659                items,
660                source: CandidateSource::Config,
661                notes: enumeration.notes().to_vec(),
662                listed_directory: None,
663            })
664        }
665        RemoteCompletionQuery::Path { segment, .. } => {
666            let Some(dir) = directory else {
667                return Outcome::failed(
668                    FailureKind::Protocol,
669                    "path completion without a listing target",
670                );
671            };
672            let prefix = lenient_percent_decode(&segment);
673            match client.list_connected(&dir, &cancel) {
674                Ok(entries) => {
675                    let mut items: Vec<RemoteCandidate> = entries
676                        .into_iter()
677                        .filter_map(|entry| {
678                            // `.`/`..` have no file_name; browsing owns
679                            // parent navigation, completion owns names.
680                            let name = entry.file.path().file_name()?;
681                            if !name.as_encoded_bytes().starts_with(&prefix) {
682                                return None;
683                            }
684                            let directory = matches!(entry.kind, RemoteEntryKind::Directory);
685                            let mut uri = entry.file.to_string();
686                            if directory && !uri.ends_with('/') {
687                                uri.push('/');
688                            }
689                            Some(RemoteCandidate { uri, directory })
690                        })
691                        .collect();
692                    items.sort_by(|a, b| {
693                        b.directory
694                            .cmp(&a.directory)
695                            .then_with(|| a.uri.cmp(&b.uri))
696                    });
697                    let listed = dir.to_string();
698                    Outcome::Success(RemoteCompletionResult::Candidates {
699                        items,
700                        source: CandidateSource::Connection,
701                        notes: Vec::new(),
702                        listed_directory: Some(listed),
703                    })
704                }
705                Err(_not_connected) => {
706                    if cancel.is_cancelled() {
707                        return Outcome::Cancelled(CancelReason::OwnerClosed);
708                    }
709                    if let Some(cached) = fallback {
710                        return Outcome::Success(RemoteCompletionResult::Candidates {
711                            items: cached,
712                            source: CandidateSource::Cache,
713                            notes: Vec::new(),
714                            listed_directory: None,
715                        });
716                    }
717                    Outcome::Success(RemoteCompletionResult::ConnectRequired {
718                        endpoint: endpoint_display(&dir),
719                    })
720                }
721            }
722        }
723    }
724}
725
726/// The authority region of a canonical URI, for the connect
727/// instruction.
728fn endpoint_display(file: &RemoteFile) -> String {
729    let uri = file.to_string();
730    let rest = uri.strip_prefix("ssh://").unwrap_or(&uri);
731    let end = rest.find('/').unwrap_or(rest.len());
732    format!("ssh://{}", &rest[..end])
733}
734
735/// Decode a typed segment for native prefix matching. Malformed or
736/// half-typed escapes stay literal: this filters names, it never
737/// admits one — the applied candidate is always a canonical URI.
738fn lenient_percent_decode(text: &str) -> Vec<u8> {
739    fn hex_value(byte: u8) -> u8 {
740        match byte {
741            b'0'..=b'9' => byte - b'0',
742            b'a'..=b'f' => byte - b'a' + 10,
743            _ => byte - b'A' + 10,
744        }
745    }
746    let bytes = text.as_bytes();
747    let mut out = Vec::with_capacity(bytes.len());
748    let mut at = 0;
749    while at < bytes.len() {
750        if bytes[at] == b'%' {
751            let high = bytes.get(at + 1).copied().filter(|b| b.is_ascii_hexdigit());
752            let low = bytes.get(at + 2).copied().filter(|b| b.is_ascii_hexdigit());
753            if let (Some(high), Some(low)) = (high, low) {
754                out.push((hex_value(high) << 4) | hex_value(low));
755                at += 3;
756                continue;
757            }
758        }
759        out.push(bytes[at]);
760        at += 1;
761    }
762    out
763}