Skip to main content

cli_ui/prompt/
path.rs

1//! Filesystem path picker — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, DIM, WHITE};
4use std::path::{Path, PathBuf};
5
6use super::core::{Frame, Key, Prompt, RenderCtx, Step};
7use super::error::{PromptError, Result};
8use super::theme;
9
10/// Builder returned by [`path()`].
11pub struct PathPrompt {
12    label: String,
13    initial: Option<String>,
14    directory: bool,
15    max_items: usize,
16    buf: String,
17    cur: usize,
18}
19
20/// Pick a filesystem path with Tab completion. Defaults to the current
21/// working directory.
22///
23/// ```no_run
24/// use cli_ui::prompt::path::path;
25///
26/// let project = path("Where to scaffold?").directory(true).run()?;
27/// # Ok::<(), cli_ui::prompt::PromptError>(())
28/// ```
29pub fn path(label: impl Into<String>) -> PathPrompt {
30    PathPrompt {
31        label: label.into(),
32        initial: None,
33        directory: false,
34        max_items: 8,
35        buf: String::new(),
36        cur: 0,
37    }
38}
39
40impl PathPrompt {
41    /// Initial path shown — falls back to the current working directory.
42    pub fn initial(mut self, v: impl Into<String>) -> Self {
43        self.initial = Some(v.into());
44        self
45    }
46    /// When `true`, only accept directories.
47    pub fn directory(mut self, v: bool) -> Self {
48        self.directory = v;
49        self
50    }
51    /// Maximum number of completion suggestions shown at once.
52    pub fn max_items(mut self, n: usize) -> Self {
53        self.max_items = n.max(1);
54        self
55    }
56
57    /// Run the prompt to completion.
58    pub fn run(mut self) -> Result<PathBuf> {
59        self.buf = self.initial.clone().unwrap_or_else(|| {
60            std::env::current_dir()
61                .map(|p| p.to_string_lossy().into_owned())
62                .unwrap_or_default()
63        });
64        if !self.buf.ends_with('/') {
65            self.buf.push('/');
66        }
67        super::core::run(self)
68    }
69
70    fn entries(&self) -> Vec<Entry> {
71        let (dir, fragment) = split_path(&self.buf);
72        let mut out = Vec::new();
73        if let Ok(rd) = std::fs::read_dir(&dir) {
74            for ent in rd.flatten() {
75                let name = ent.file_name().to_string_lossy().into_owned();
76                if !name.starts_with(fragment) {
77                    continue;
78                }
79                let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
80                if self.directory && !is_dir {
81                    continue;
82                }
83                out.push(Entry {
84                    name,
85                    path: ent.path(),
86                    is_dir,
87                });
88            }
89        }
90        out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
91        out.truncate(self.max_items);
92        out
93    }
94}
95
96impl Prompt for PathPrompt {
97    type Output = PathBuf;
98
99    fn handle(&mut self, key: Key) -> Step<PathBuf> {
100        match key {
101            Key::Char('\t') => {
102                let entries = self.entries();
103                if let Some(e) = entries.get(self.cur) {
104                    self.buf = e.path.to_string_lossy().into_owned();
105                    if e.is_dir {
106                        self.buf.push('/');
107                    }
108                    self.cur = 0;
109                }
110                Step::Continue
111            }
112            Key::Char(c) => {
113                self.buf.push(c);
114                self.cur = 0;
115                Step::Continue
116            }
117            Key::Backspace => {
118                self.buf.pop();
119                self.cur = 0;
120                Step::Continue
121            }
122            Key::Up => {
123                if self.cur > 0 {
124                    self.cur -= 1;
125                }
126                Step::Continue
127            }
128            Key::Down => {
129                if self.cur + 1 < self.entries().len() {
130                    self.cur += 1;
131                }
132                Step::Continue
133            }
134            Key::Enter => {
135                let p = PathBuf::from(&self.buf);
136                if self.directory && !p.is_dir() {
137                    return Step::Reject("Not a directory".into());
138                }
139                Step::Submit(p)
140            }
141            Key::Escape | Key::Interrupt => Step::Cancel,
142            _ => Step::Continue,
143        }
144    }
145
146    fn render(&self, _ctx: RenderCtx) -> Frame {
147        let c = super::settings::colors();
148        let mut f: Frame = Vec::new();
149        f.push(theme::label(&self.label));
150        f.push(format!(
151            "{}  {}",
152            paint(c.dim, "│"),
153            paint(c.input, &self.buf)
154        ));
155        let entries = self.entries();
156        if entries.is_empty() {
157            f.push(format!(
158                "{}  {}",
159                paint(c.dim, "│"),
160                paint(c.dim, "(no matches)")
161            ));
162        } else {
163            for (i, e) in entries.iter().enumerate() {
164                let active = i == self.cur;
165                let glyph = if e.is_dir { "/" } else { " " };
166                let style = if active { WHITE } else { DIM };
167                let prefix = if active {
168                    paint(c.accent, "❯")
169                } else {
170                    paint(c.dim, " ")
171                };
172                f.push(format!(
173                    "{}  {} {}{}",
174                    paint(c.dim, "│"),
175                    prefix,
176                    paint(style, &e.name),
177                    paint(c.dim, glyph)
178                ));
179            }
180        }
181        f.push(theme::hint(
182            "Tab to complete · ↑ ↓ to navigate · Enter to confirm",
183        ));
184        f.push(theme::frame_bot(None));
185        f
186    }
187
188    fn render_answered(&self, value: &PathBuf) -> Frame {
189        theme::answered(&self.label, &value.display().to_string())
190            .split("\r\n")
191            .map(String::from)
192            .collect()
193    }
194
195    fn run_fallback(self) -> Result<PathBuf> {
196        use std::io::Write;
197        let mut out = std::io::stderr();
198        write!(out, "  {}: ", self.label).map_err(PromptError::Io)?;
199        out.flush().map_err(PromptError::Io)?;
200        let line = super::engine::fallback::read_line_raw()?;
201        Ok(PathBuf::from(line))
202    }
203}
204
205struct Entry {
206    name: String,
207    path: PathBuf,
208    is_dir: bool,
209}
210
211fn split_path(buf: &str) -> (PathBuf, &str) {
212    if let Some(pos) = buf.rfind('/') {
213        let (dir, rest) = buf.split_at(pos + 1);
214        let dir = if dir.is_empty() {
215            "/".into()
216        } else {
217            PathBuf::from(dir)
218        };
219        (dir, rest)
220    } else {
221        (Path::new(".").to_path_buf(), buf)
222    }
223}