1use std::collections::VecDeque;
22use std::path::PathBuf;
23use std::sync::mpsc::{self, Receiver, Sender};
24
25use strop_core::id::{BufferRevision, DocumentId};
26use strop_core::worker::{self, CancelReason, Completion, FailureKind, Outcome, Ticket};
27use strop_remote::{HostCandidate, HostSources, RemoteClient, RemoteEntryKind};
28use strop_workspace::RemoteFile;
29
30use super::document::DocumentSource;
31use super::pending::{PendingEvent, PromptContext};
32use super::Editor;
33
34#[cfg(test)]
35mod tests;
36
37const CACHE_DIRS: usize = 32;
40
41fn remote_operand_shape(command: &str) -> Option<(usize, usize)> {
46 match command {
47 "e" | "e!" | "view" | "sp" | "split" | "vs" | "vsplit" | "browse" | "follow" => {
48 Some((0, 0))
49 }
50 "tail" => Some((0, 1)),
52 "range" => Some((2, 2)),
54 _ => None,
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum RemoteCompletionQuery {
63 Hosts { partial: String },
65 Path {
70 authority: String,
71 directory: String,
72 segment: String,
73 },
74}
75
76impl RemoteCompletionQuery {
77 fn label(&self) -> &'static str {
78 match self {
79 Self::Hosts { .. } => "hosts",
80 Self::Path { .. } => "path",
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub struct RemoteCandidate {
90 pub uri: String,
91 pub directory: bool,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub enum CandidateSource {
98 Config,
99 Connection,
100 Cache,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub struct RemoteCompletionKey {
106 pub focus: u64,
107 pub document: DocumentId,
108 pub revision: BufferRevision,
109 pub text: String,
111 pub cursor: usize,
112 pub query: RemoteCompletionQuery,
113}
114
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118pub enum RemoteCompletionResult {
119 Candidates {
120 items: Vec<RemoteCandidate>,
121 source: CandidateSource,
122 notes: Vec<String>,
124 listed_directory: Option<String>,
127 },
128 ConnectRequired { endpoint: String },
131}
132
133pub type RemoteCompletionEvent = Completion<RemoteCompletionKey, RemoteCompletionResult>;
135
136#[derive(Debug, Clone)]
139struct ReadyCompletion {
140 prefix_body: String,
142 applied: String,
145 candidates: Vec<RemoteCandidate>,
146 index: usize,
147}
148
149#[derive(Debug)]
152pub(crate) struct RemoteCompletionState {
153 pub tx: Sender<RemoteCompletionEvent>,
156 pub rx: Option<Receiver<RemoteCompletionEvent>>,
157 pub(crate) pending: Option<Ticket<RemoteCompletionKey>>,
158 ready: Option<ReadyCompletion>,
159 cache: VecDeque<(String, Vec<RemoteCandidate>)>,
160}
161
162impl Default for RemoteCompletionState {
163 fn default() -> Self {
164 let (tx, rx) = mpsc::channel();
165 Self {
166 tx,
167 rx: Some(rx),
168 pending: None,
169 ready: None,
170 cache: VecDeque::new(),
171 }
172 }
173}
174
175impl RemoteCompletionState {
176 fn cached(&self, canonical_dir: &str) -> Option<Vec<RemoteCandidate>> {
177 self.cache
178 .iter()
179 .rev()
180 .find(|(key, _)| key == canonical_dir)
181 .map(|(_, items)| items.clone())
182 }
183
184 fn store_cache(&mut self, canonical_dir: String, items: Vec<RemoteCandidate>) {
185 if items.is_empty() {
186 return;
187 }
188 self.cache.retain(|(key, _)| key != &canonical_dir);
189 self.cache.push_back((canonical_dir, items));
190 while self.cache.len() > CACHE_DIRS {
191 self.cache.pop_front();
192 }
193 }
194
195 #[cfg(test)]
196 fn ticket(&self) -> Option<Ticket<RemoteCompletionKey>> {
197 self.pending.clone()
198 }
199}
200
201impl Editor {
202 pub(crate) fn remote_completion_tab(&mut self) -> bool {
207 let Some((text, cursor)) = self
208 .pending
209 .prompt()
210 .map(|prompt| (prompt.text().to_owned(), prompt.cursor()))
211 else {
212 return false;
213 };
214 let Some(body) = text.strip_prefix(':') else {
215 return false;
216 };
217 let Some((cmd, rest)) = body.split_once(' ') else {
218 return false;
219 };
220 let tokens: Vec<&str> = rest.split(' ').filter(|token| !token.is_empty()).collect();
221 let Some(operand) = tokens.last().copied() else {
222 return false;
223 };
224 if !operand.starts_with("ssh://") {
225 if tokens.iter().any(|token| token.starts_with("ssh://")) {
228 self.message = "remote URI cannot contain a raw space (type %20)".into();
229 return true;
230 }
231 return false;
232 }
233 if matches!(cmd, "w" | "w!" | "wq" | "wq!") {
234 self.message = "remote save-as completion is unsupported".into();
235 return true;
236 }
237 let Some((min_args, max_args)) = remote_operand_shape(cmd) else {
238 return false;
239 };
240 let leading = tokens.len() - 1;
241 if leading < min_args || leading > max_args {
242 self.message = match cmd {
243 "range" => ":range needs START BYTES before the URI".into(),
244 "tail" => ":tail takes at most one byte count before the URI".into(),
245 _ => format!(":{cmd} takes no argument before the URI"),
246 };
247 return true;
248 }
249 if cursor != text.len() {
250 self.message = "completion needs the cursor at the end of the line".into();
251 return true;
252 }
253 if let Some(ready) = self.remote_completion.ready.as_ref() {
254 if self.pending.text() == ready.applied && ready.candidates.len() > 1 {
255 let next = (ready.index + 1) % ready.candidates.len();
256 let uri = ready.candidates[next].uri.clone();
257 let prefix = ready.prefix_body.clone();
258 self.apply_completion(&prefix, &uri);
259 if let Some(ready) = self.remote_completion.ready.as_mut() {
260 ready.index = next;
261 ready.applied = self.pending.text().to_owned();
262 }
263 return true;
264 }
265 }
267 let typed = operand.strip_prefix("ssh://").unwrap_or_default();
268 self.start_remote_completion(typed);
269 true
270 }
271
272 fn start_remote_completion(&mut self, typed: &str) {
274 let query = match classify_remote_operand(typed) {
275 Ok(query) => query,
276 Err(message) => {
277 self.message = message;
278 return;
279 }
280 };
281 let Some((text, cursor, document, revision)) =
282 self.pending
283 .prompt()
284 .and_then(|prompt| match prompt.context() {
285 PromptContext::Ex(origin) => Some((
286 prompt.text().to_owned(),
287 prompt.cursor(),
288 origin.pane.doc,
289 origin.revision,
290 )),
291 _ => None,
292 })
293 else {
294 return;
295 };
296 let (dir_file, fallback) = match &query {
300 RemoteCompletionQuery::Path {
301 authority,
302 directory,
303 ..
304 } => match RemoteFile::parse(&format!("ssh://{authority}{directory}")) {
305 Ok(file) => {
306 let fallback = self.remote_completion.cached(&file.to_string());
307 (Some(file), fallback)
308 }
309 Err(error) => {
310 self.message = format!("invalid remote address: {error}");
311 return;
312 }
313 },
314 RemoteCompletionQuery::Hosts { .. } => (None, None),
315 };
316 if let Some(old) = self.remote_completion.pending.take() {
319 if let Some(handle) = self.worker_handles.remove(&old.request) {
320 handle.cancel(CancelReason::Superseded);
321 }
322 }
323 self.remote_completion.ready = None;
324 let key = RemoteCompletionKey {
325 focus: self.focus_epoch,
326 document,
327 revision,
328 text,
329 cursor,
330 query: query.clone(),
331 };
332 let request = match self.worker_ids.allocate() {
333 Ok(request) => request,
334 Err(error) => {
335 self.message = error.message;
336 return;
337 }
338 };
339 let ticket = Ticket {
340 request,
341 key: key.clone(),
342 };
343 self.remote_completion.pending = Some(ticket.clone());
344 self.message = "completing…".into();
345 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
346 serde_json::json!({
347 "service":"remote-completion","request":request.get(),
348 "query":query.label(),
349 })
350 });
351 match self.tape.request("remote.completion", &ticket) {
352 Ok(false) => return,
353 Ok(true) => {}
354 Err(error) => {
355 self.handle_remote_completion(Completion {
356 ticket,
357 outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
358 });
359 return;
360 }
361 }
362 let sources = completion_host_sources();
363 let history = self.remote_history();
364 let client = self.remote_client();
365 let tx = self.remote_completion.tx.clone();
366 let handle = worker::spawn(
367 "strop-remote-complete",
368 move |outcome| {
369 let _ = tx.send(Completion { ticket, outcome });
370 },
371 move |cancel| {
372 run_completion(query, dir_file, fallback, sources, history, client, cancel)
373 },
374 );
375 self.worker_handles.insert(request, handle);
376 }
377
378 pub(crate) fn handle_remote_completion(&mut self, event: RemoteCompletionEvent) {
381 if self.remote_completion.pending.as_ref() != Some(&event.ticket) {
382 strop_trace::record_with(
383 strop_trace::EventKind::JobRejected,
384 || serde_json::json!({"service":"remote-completion","reason":"superseded"}),
385 );
386 return;
387 }
388 let ticket = event.ticket;
389 self.remote_completion.pending = None;
390 self.worker_handles.remove(&ticket.request);
391 if !self.completion_prompt_fresh(&ticket.key) {
392 strop_trace::record_with(
393 strop_trace::EventKind::JobRejected,
394 || serde_json::json!({"service":"remote-completion","reason":"stale prompt"}),
395 );
396 return;
397 }
398 match event.outcome {
399 Outcome::Success(RemoteCompletionResult::Candidates {
400 items,
401 source,
402 notes,
403 listed_directory,
404 }) => {
405 if let Some(directory) = &listed_directory {
406 self.remote_completion
407 .store_cache(directory.clone(), items.clone());
408 }
409 if items.is_empty() {
410 self.message = match notes.first() {
411 Some(note) => format!("no remote matches: {note}"),
412 None => "no remote matches".into(),
413 };
414 return;
415 }
416 let prefix_body = completion_prefix_body(&ticket.key);
417 self.apply_completion(&prefix_body, &items[0].uri);
418 self.remote_completion.ready = Some(ReadyCompletion {
419 prefix_body,
420 applied: self.pending.text().to_owned(),
421 candidates: items.clone(),
422 index: 0,
423 });
424 self.message = candidates_message(&items, source);
425 }
426 Outcome::Success(RemoteCompletionResult::ConnectRequired { endpoint }) => {
427 self.message = format!(
428 "no live connection to {endpoint}; completion never connects \
429 — open or browse the remote first"
430 );
431 }
432 Outcome::Failed { failure, .. } => self.message = failure.message,
433 Outcome::Cancelled(_) => {}
434 }
435 }
436
437 fn completion_prompt_fresh(&self, key: &RemoteCompletionKey) -> bool {
440 let Some(prompt) = self.pending.prompt() else {
441 return false;
442 };
443 matches!(prompt.context(), PromptContext::Ex(_))
444 && !self.docs.is_empty()
445 && self.current() == key.document
446 && self.focus_epoch == key.focus
447 && self.buf().revision() == key.revision
448 && prompt.text() == key.text
449 && prompt.cursor() == key.cursor
450 }
451
452 fn apply_completion(&mut self, prefix_body: &str, uri: &str) {
454 self.feed_pending_event(PendingEvent::CompleteEx(format!("{prefix_body}{uri}")));
455 }
456
457 fn remote_history(&self) -> Vec<HostCandidate> {
459 self.docs
460 .iter()
461 .filter_map(|(_, document)| match &document.source {
462 DocumentSource::Remote(file) => {
463 let endpoint = file.file.endpoint();
464 Some(HostCandidate::new(
465 endpoint.host().to_owned(),
466 endpoint.user().map(str::to_owned),
467 endpoint.port(),
468 strop_remote::CandidateOrigin::History,
469 ))
470 }
471 _ => None,
472 })
473 .collect()
474 }
475}
476
477fn completion_prefix_body(key: &RemoteCompletionKey) -> String {
481 let body = key.text.strip_prefix(':').unwrap_or(&key.text);
482 match body.rfind("ssh://") {
483 Some(at) => body[..at].to_owned(),
484 None => body.to_owned(),
485 }
486}
487
488fn candidates_message(items: &[RemoteCandidate], source: CandidateSource) -> String {
490 let mut text = items
491 .iter()
492 .take(6)
493 .map(display_segment)
494 .collect::<Vec<_>>()
495 .join(" ");
496 if items.len() > 6 {
497 text.push_str(&format!(" (+{})", items.len() - 6));
498 }
499 match source {
500 CandidateSource::Cache => text.push_str(" (cached)"),
501 CandidateSource::Connection => text.push_str(" (live)"),
502 CandidateSource::Config => {}
503 }
504 if text.len() > 160 {
505 text.truncate(160);
506 }
507 text
508}
509
510fn display_segment(candidate: &RemoteCandidate) -> &str {
514 let uri = &candidate.uri;
515 let cut = match uri.rfind('/') {
516 Some(at) if at + 1 == uri.len() => uri[..at].rfind('/').map_or(at, |prev| prev + 1),
517 Some(at) => at + 1,
518 None => return uri.strip_prefix("ssh://").unwrap_or(uri),
519 };
520 &uri[cut..]
521}
522
523fn classify_remote_operand(typed: &str) -> Result<RemoteCompletionQuery, String> {
528 let refuse_home =
529 || "cannot complete `~` paths: open the remote file so its home resolves first".to_string();
530 if typed.starts_with('~') {
531 return Err(refuse_home());
532 }
533 let Some((authority, path)) = typed.split_once('/') else {
534 return Ok(RemoteCompletionQuery::Hosts {
535 partial: typed.to_owned(),
536 });
537 };
538 if authority.is_empty() {
539 return Err("ssh:// needs a host before the path".to_string());
540 }
541 if path.split('/').next() == Some("~") {
542 return Err(refuse_home());
543 }
544 let (directory, segment) = match path.rsplit_once('/') {
545 Some((before, last)) => (format!("/{before}"), last.to_owned()),
546 None => ("/".to_owned(), path.to_owned()),
547 };
548 Ok(RemoteCompletionQuery::Path {
549 authority: authority.to_owned(),
550 directory,
551 segment,
552 })
553}
554
555fn completion_host_sources() -> HostSources {
558 let home = std::env::var_os("HOME").map(PathBuf::from);
559 HostSources::discover(home.as_deref())
560}
561
562fn run_completion(
567 query: RemoteCompletionQuery,
568 directory: Option<RemoteFile>,
569 fallback: Option<Vec<RemoteCandidate>>,
570 sources: HostSources,
571 history: Vec<HostCandidate>,
572 client: RemoteClient,
573 cancel: worker::CancelToken,
574) -> Outcome<RemoteCompletionResult> {
575 if cancel.is_cancelled() {
576 return Outcome::Cancelled(CancelReason::OwnerClosed);
577 }
578 match query {
579 RemoteCompletionQuery::Hosts { partial } => {
580 let enumeration = strop_remote::enumerate_hosts(&sources, &history);
581 let items = enumeration
582 .complete(&partial)
583 .into_iter()
584 .map(|token| RemoteCandidate {
585 uri: format!("ssh://{token}"),
586 directory: false,
587 })
588 .collect();
589 Outcome::Success(RemoteCompletionResult::Candidates {
590 items,
591 source: CandidateSource::Config,
592 notes: enumeration.notes().to_vec(),
593 listed_directory: None,
594 })
595 }
596 RemoteCompletionQuery::Path { segment, .. } => {
597 let Some(dir) = directory else {
598 return Outcome::failed(
599 FailureKind::Protocol,
600 "path completion without a listing target",
601 );
602 };
603 let prefix = lenient_percent_decode(&segment);
604 match client.list_connected(&dir, &cancel) {
605 Ok(entries) => {
606 let mut items: Vec<RemoteCandidate> = entries
607 .into_iter()
608 .filter_map(|entry| {
609 let name = entry.file.path().file_name()?;
612 if !name.as_encoded_bytes().starts_with(&prefix) {
613 return None;
614 }
615 let directory = matches!(entry.kind, RemoteEntryKind::Directory);
616 let mut uri = entry.file.to_string();
617 if directory && !uri.ends_with('/') {
618 uri.push('/');
619 }
620 Some(RemoteCandidate { uri, directory })
621 })
622 .collect();
623 items.sort_by(|a, b| {
624 b.directory
625 .cmp(&a.directory)
626 .then_with(|| a.uri.cmp(&b.uri))
627 });
628 let listed = dir.to_string();
629 Outcome::Success(RemoteCompletionResult::Candidates {
630 items,
631 source: CandidateSource::Connection,
632 notes: Vec::new(),
633 listed_directory: Some(listed),
634 })
635 }
636 Err(_not_connected) => {
637 if cancel.is_cancelled() {
638 return Outcome::Cancelled(CancelReason::OwnerClosed);
639 }
640 if let Some(cached) = fallback {
641 return Outcome::Success(RemoteCompletionResult::Candidates {
642 items: cached,
643 source: CandidateSource::Cache,
644 notes: Vec::new(),
645 listed_directory: None,
646 });
647 }
648 Outcome::Success(RemoteCompletionResult::ConnectRequired {
649 endpoint: endpoint_display(&dir),
650 })
651 }
652 }
653 }
654 }
655}
656
657fn endpoint_display(file: &RemoteFile) -> String {
660 let uri = file.to_string();
661 let rest = uri.strip_prefix("ssh://").unwrap_or(&uri);
662 let end = rest.find('/').unwrap_or(rest.len());
663 format!("ssh://{}", &rest[..end])
664}
665
666fn lenient_percent_decode(text: &str) -> Vec<u8> {
670 fn hex_value(byte: u8) -> u8 {
671 match byte {
672 b'0'..=b'9' => byte - b'0',
673 b'a'..=b'f' => byte - b'a' + 10,
674 _ => byte - b'A' + 10,
675 }
676 }
677 let bytes = text.as_bytes();
678 let mut out = Vec::with_capacity(bytes.len());
679 let mut at = 0;
680 while at < bytes.len() {
681 if bytes[at] == b'%' {
682 let high = bytes.get(at + 1).copied().filter(|b| b.is_ascii_hexdigit());
683 let low = bytes.get(at + 2).copied().filter(|b| b.is_ascii_hexdigit());
684 if let (Some(high), Some(low)) = (high, low) {
685 out.push((hex_value(high) << 4) | hex_value(low));
686 at += 3;
687 continue;
688 }
689 }
690 out.push(bytes[at]);
691 at += 1;
692 }
693 out
694}