1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! Tab path completion policy for the inline shell composer.
//!
//! Shell mode has no `@` or `/` palette, so `Tab` on the word under the
//! cursor completes it the way a shell would: one path component at a time,
//! against the entries of one directory. The list, its navigation, and its
//! cache are the ordinary path palette in `file_palette`; this module owns
//! what differs: when Tab opens it, which candidates it offers, and how a
//! path is written so the shell reads it as one word.
use std::path::Path;
use super::{
file_picker::{self, DirectoryScope, DiscoveredFilePaths},
inline_shell_config::{resolve_shell, ShellFamily},
App,
};
use crate::paths::home_dir;
impl App {
/// Complete the word under the cursor like a shell would: a lone match is
/// inserted at once, several open the palette, none leaves the composer
/// alone and says so.
pub(super) fn open_shell_completion(&mut self) -> anyhow::Result<()> {
let config = self.info.services.config_repository.load()?;
let shell = resolve_shell(&config.inline_shell);
if !ShellFamily::for_executable(&shell).supports_path_completion() {
self.close_file_palette();
self.set_status("path completion requires a POSIX-style shell; command left unchanged");
return Ok(());
}
let anchor =
file_picker::shell_word_at_cursor(self.input_ui.text(), self.input_ui.cursor()).start;
self.input_ui.set_shell_completion_anchor(Some(anchor));
self.input_ui.set_file_palette_dismissed(false);
self.input_ui.set_file_selection(0);
let matches = self.file_match_list();
match matches.len() {
0 => {
self.set_status("no matching paths");
}
1 => {
if let Some(entry) = matches.get(0) {
self.apply_file_palette_selection(&entry)?;
}
}
_ => {}
}
Ok(())
}
}
/// Entries of the directory `word` names, filtered to those whose name
/// starts with the word's last component, in a shell's order.
///
/// This is a plain directory listing rather than the workspace index because
/// a shell sees the filesystem as it is: `target/`, `.git/`, and untracked
/// files are all valid arguments. Hidden entries still hide until the
/// component starts with `.`, as in bash.
///
/// The trailing `/` on a directory is load-bearing on both sides: it is what
/// the composer shows and inserts, and it is how the write-back knows to
/// leave off the space so the next Tab descends. Do not strip it.
pub(super) fn shell_word_candidates(cwd: &Path, word: &str) -> DiscoveredFilePaths {
shell_word_candidates_in(cwd, word, home_dir().as_deref())
}
fn shell_word_candidates_in(cwd: &Path, word: &str, home: Option<&Path>) -> DiscoveredFilePaths {
let (scope, partial) = match file_picker::directory_scope(cwd, word, home) {
Some((scope, residual)) => (scope, residual),
// A word with a `/` that names no existing directory has nothing to
// offer. A bare word lists `cwd`.
None if word.contains('/') => return DiscoveredFilePaths::complete(Vec::new()),
None => (
DirectoryScope {
root: cwd.to_path_buf(),
display_prefix: String::new(),
},
word.to_string(),
),
};
let show_hidden = partial.starts_with('.');
// Deliberately retain every matching entry from this one directory, not
// the recursive workspace index. This costs memory proportional to the
// matches but never hides a valid shell argument behind a result cap.
let mut paths: Vec<String> = std::fs::read_dir(&scope.root)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
if !name.starts_with(&partial) || (!show_hidden && name.starts_with('.')) {
return None;
}
// Labelling only, not walking: following a symlink here decides
// whether the next Tab descends, as bash does.
let suffix = if entry.path().is_dir() { "/" } else { "" };
// A literal cwd entry must not acquire home expansion on insert
// or on the next Tab. Keep intentional ~/ scope prefixes intact.
let prefix = if scope.display_prefix.is_empty() && name.starts_with('~') {
"./"
} else {
&scope.display_prefix
};
Some(format!("{prefix}{name}{suffix}"))
})
.collect();
file_picker::sort_paths_for_display(&mut paths);
DiscoveredFilePaths::complete(paths)
}
/// Single-quote `path` for the POSIX-style shells accepted above.
pub(super) fn shell_quote(path: &str) -> String {
// Keep home expansion outside quotes when a later component needs them.
if let Some(relative) = path.strip_prefix("~/") {
return format!("~/{}", shell_quote(relative));
}
let safe = |ch: char| ch.is_alphanumeric() || "-_./~+:@%,=".contains(ch);
if !path.is_empty() && !path.starts_with('~') && path.chars().all(safe) {
return path.to_string();
}
format!("'{}'", path.replace('\'', "'\\''"))
}
#[cfg(test)]
#[path = "shell_palette_tests.rs"]
mod tests;