1use 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
26const CACHE_DIRS: usize = 32;
29
30fn 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" => Some((0, 1)),
41 "range" => Some((2, 2)),
43 _ => None,
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum RemoteCompletionQuery {
52 Hosts { partial: String },
54 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct RemoteCandidate {
85 pub uri: String,
86 pub directory: bool,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub enum CandidateSource {
93 Config,
94 Connection,
95 Cache,
96 Directory,
97}
98
99#[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 pub text: String,
107 pub cursor: usize,
108 pub query: RemoteCompletionQuery,
109 pub prefix_body: String,
110}
111
112#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
115pub enum RemoteCompletionResult {
116 Candidates {
117 items: Vec<RemoteCandidate>,
118 source: CandidateSource,
119 notes: Vec<String>,
121 listed_directory: Option<String>,
124 },
125 ConnectRequired { endpoint: String },
128}
129
130pub type RemoteCompletionEvent = Completion<RemoteCompletionKey, RemoteCompletionResult>;
132
133#[derive(Debug, Clone)]
136struct ReadyCompletion {
137 prefix_body: String,
139 applied: String,
142 candidates: Vec<RemoteCandidate>,
143 index: usize,
144}
145
146#[derive(Debug)]
149pub(crate) struct RemoteCompletionState {
150 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 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 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 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 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 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 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 fn apply_completion(&mut self, prefix_body: &str, uri: &str) {
524 self.feed_pending_event(PendingEvent::CompleteEx(format!("{prefix_body}{uri}")));
525 }
526
527 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
547fn 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
574fn 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
587fn 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
619fn completion_host_sources() -> HostSources {
622 let home = std::env::var_os("HOME").map(PathBuf::from);
623 HostSources::discover(home.as_deref())
624}
625
626fn 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 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
726fn 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
735fn 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}