Skip to main content

atomcode_tuix/modals/
dir_picker.rs

1// crates/atomcode-tuix/src/modals/dir_picker.rs
2//
3// `/cd` (no argument) modal — recent-project-dirs picker.
4//
5// Lists the up-to-5 most recently visited project directories from
6// `ctx.recent_dirs` (backed by `~/.atomcode/recent_dirs.txt`). Up/Down
7// navigates, Enter commits the cd via `apply_cd`, Esc cancels.
8
9use std::path::PathBuf;
10
11use anyhow::Result;
12use crossterm::event::{KeyCode, KeyModifiers};
13
14use super::{Modal, ModalAction};
15use crate::event_loop::commands::apply_cd;
16use crate::event_loop::{build_status, Buffer, LoopCtx};
17use crate::render::{MenuPayload, Renderer, UiLine};
18use crate::state::UiState;
19
20pub struct DirPicker {
21    /// Snapshot of recent dirs at open time. Already in "most recent
22    /// first" order; includes the current working directory at index 0
23    /// (seeded at startup / refreshed on every `apply_cd`).
24    pub dirs: Vec<PathBuf>,
25    /// The working dir at open time — used to label the matching entry
26    /// as `(current)` so users can tell which one they're already on.
27    pub current: PathBuf,
28    /// Index into `dirs`.
29    pub selected: usize,
30}
31
32impl DirPicker {
33    pub fn open(dirs: Vec<PathBuf>, current: PathBuf) -> Self {
34        Self {
35            dirs,
36            current,
37            selected: 0,
38        }
39    }
40
41    fn up(&mut self) {
42        self.selected = self.selected.saturating_sub(1);
43    }
44
45    fn down(&mut self) {
46        if self.dirs.is_empty() {
47            self.selected = 0;
48            return;
49        }
50        let max = self.dirs.len() - 1;
51        if self.selected < max {
52            self.selected += 1;
53        }
54    }
55
56    fn chosen(&self) -> Option<PathBuf> {
57        self.dirs.get(self.selected).cloned()
58    }
59}
60
61impl Modal for DirPicker {
62    fn handle_key(
63        &mut self,
64        code: KeyCode,
65        _mods: KeyModifiers,
66        buf: &mut Buffer,
67        state: &mut UiState,
68        ctx: &mut LoopCtx,
69        renderer: &mut dyn Renderer,
70    ) -> Result<ModalAction> {
71        match code {
72            KeyCode::Up => {
73                self.up();
74                self.draw(buf, state, ctx, renderer);
75                Ok(ModalAction::Continue)
76            }
77            KeyCode::Down => {
78                self.down();
79                self.draw(buf, state, ctx, renderer);
80                Ok(ModalAction::Continue)
81            }
82            KeyCode::Enter => {
83                let Some(path) = self.chosen() else {
84                    return Ok(ModalAction::Continue);
85                };
86                if path == ctx.working_dir {
87                    // No-op cd: skip the agent round-trip but still close
88                    // the picker so the user isn't stuck inside it.
89                    return Ok(ModalAction::Close);
90                }
91                if !path.is_dir() {
92                    let p = path.display().to_string();
93                    renderer.render(UiLine::Error(
94                        crate::i18n::t(crate::i18n::Msg::DirNotExists { path: &p }).into_owned(),
95                    ));
96                    renderer.flush();
97                    return Ok(ModalAction::Close);
98                }
99                apply_cd(ctx, path.clone());
100                let p = path.display().to_string();
101                renderer.render(UiLine::CommandOutput(
102                    crate::i18n::t(crate::i18n::Msg::DirChanged { path: &p }).into_owned(),
103                ));
104                renderer.flush();
105                Ok(ModalAction::Close)
106            }
107            KeyCode::Esc => Ok(ModalAction::Close),
108            _ => Ok(ModalAction::Continue),
109        }
110    }
111
112    fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
113        let payload = build_menu_payload(self);
114        renderer.render(UiLine::InputPrompt {
115            buf: buf.text.clone(),
116            cursor_byte: buf.cursor,
117            menu: Some(payload),
118            status: build_status(state, ctx),
119            attachments: Vec::new(),
120        });
121        renderer.flush();
122    }
123}
124
125fn build_menu_payload(p: &DirPicker) -> MenuPayload {
126    let items: Vec<(String, String)> = p
127        .dirs
128        .iter()
129        .map(|d| {
130            let name = crate::platform::collapse_home(&d.to_string_lossy());
131            let desc = if d == &p.current {
132                crate::i18n::t(crate::i18n::Msg::DirCurrent).into_owned()
133            } else {
134                String::new()
135            };
136            (name, desc)
137        })
138        .collect();
139    MenuPayload {
140        items,
141        selected: p.selected,
142            kind: crate::render::MenuKind::SlashCommand,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn pb(s: &str) -> PathBuf {
151        PathBuf::from(s)
152    }
153
154    #[test]
155    fn open_seeds_selection_at_zero() {
156        let p = DirPicker::open(vec![pb("/a"), pb("/b")], pb("/a"));
157        assert_eq!(p.selected, 0);
158        assert_eq!(p.dirs.len(), 2);
159    }
160
161    #[test]
162    fn down_and_up_stay_within_bounds() {
163        let mut p = DirPicker::open(vec![pb("/a"), pb("/b")], pb("/a"));
164        p.down();
165        assert_eq!(p.selected, 1);
166        p.down();
167        assert_eq!(p.selected, 1, "down at end stays put");
168        p.up();
169        assert_eq!(p.selected, 0);
170        p.up();
171        assert_eq!(p.selected, 0, "up at top stays put");
172    }
173
174    #[test]
175    fn chosen_returns_selected_path() {
176        let mut p = DirPicker::open(vec![pb("/a"), pb("/b"), pb("/c")], pb("/a"));
177        p.down();
178        assert_eq!(p.chosen(), Some(pb("/b")));
179    }
180
181    #[test]
182    fn menu_payload_marks_current_dir() {
183        let p = DirPicker::open(vec![pb("/a"), pb("/b")], pb("/b"));
184        let payload = build_menu_payload(&p);
185        assert_eq!(payload.items[0].1, "");
186        assert_eq!(payload.items[1].1, "current");
187    }
188}