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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
//! The launcher's `in` field: typing a directory and being offered one.
//!
//! [`dirs`](super::dirs) computes the suggestions; this owns the field they are
//! offered into — the editing state, the cursor, and the check that a typed path
//! is a directory that exists before a launch is allowed to take it. The split
//! is so that the matching logic can be tested without an `App` at all.
use super::*;
/// Directories the launcher's `in` field will remember, at most.
///
/// The list is a way of not having to recall a path, so it is worth being
/// generous — but it is filtered by what is typed, and a machine with hundreds
/// of transcripts would otherwise stat every project on it on every keystroke.
const MAX_KNOWN_DIRS: usize = 40;
impl App {
/// Open the launcher's directory field, prefilled with where it would go.
///
/// Prefilled with `~` spelling rather than the absolute path: that is how
/// the line already reads, and a field that changed what it showed the
/// moment it became editable would look like it had lost the setting.
pub(super) fn edit_launch_cwd(&mut self) {
self.launch_cwd_input = self
.launch_cwd
.as_ref()
.map(|dir| crate::util::tildify(&dir.to_string_lossy()))
.unwrap_or_default();
self.launch_cwd_bad = false;
self.launch_cwd_known = self.known_dirs();
self.launch_cwd_suggest();
self.mode = Mode::LaunchCwd;
self.needs_redraw = true;
}
/// Directories agents are known to have run in, newest first.
///
/// Drawn from the dashboard's own rows, which is the list of projects cctop
/// has any evidence of, plus where it was started and where this launch was
/// already headed — those two are what "in this directory" and the line the
/// field replaces already meant, and a suggestion list that omitted them
/// would look like it had forgotten them.
///
/// Only directories that still exist: a session's recorded project can have
/// been moved or deleted since, and offering one leads to the refusal this
/// list exists to avoid.
pub(super) fn known_dirs(&self) -> Vec<std::path::PathBuf> {
let mut recent: Vec<&Session> = self.sessions.iter().collect();
// Newest first, so the projects worked on today are the ones on screen
// before anything is typed.
recent.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let mut seen = HashSet::new();
self.launch_cwd
.clone()
.into_iter()
.chain(self.launch_root.clone())
.chain(
self.launch_offer
.iter()
.filter_map(|c| c.cwd().map(std::path::Path::to_path_buf)),
)
.chain(
recent
.iter()
.filter(|s| !s.label_source.is_empty())
.map(|s| std::path::PathBuf::from(&s.label_source)),
)
.filter(|dir| seen.insert(dir.clone()) && dir.is_dir())
.take(MAX_KNOWN_DIRS)
.collect()
}
/// Recompute what the field is offering, after anything that changed what
/// is in it.
///
/// The pick goes with it: a suggestion highlighted for the old text would
/// otherwise still be what Enter took, which is a directory the field is no
/// longer showing.
pub(super) fn launch_cwd_suggest(&mut self) {
self.launch_cwd_hits = dirs::suggest(&self.launch_cwd_input, &self.launch_cwd_known);
self.launch_cwd_pick = None;
}
/// Move the cursor through the suggestions, or back into the text.
///
/// Leaving the list at the top rather than wrapping to the bottom is what
/// makes the text reachable again: the field is the thing being edited, and
/// a list that cycled would trap the cursor in it.
pub(super) fn step_launch_cwd(&mut self, down: bool) {
let last = self.launch_cwd_hits.len().saturating_sub(1);
self.launch_cwd_pick = match (self.launch_cwd_pick, down) {
(_, _) if self.launch_cwd_hits.is_empty() => None,
(None, true) => Some(0),
(None, false) => Some(last),
(Some(i), true) => Some((i + 1).min(last)),
(Some(0), false) => None,
(Some(i), false) => Some(i - 1),
};
self.needs_redraw = true;
}
/// Fill in as much of the path as the suggestions agree on.
///
/// Completing re-suggests: `~/c` completing to `~/cctop/` is only useful if
/// the list then shows what is inside it, which is how a deep path gets
/// walked to without being remembered.
pub(super) fn complete_launch_cwd(&mut self) {
// The highlighted one, if the cursor is in the list — there Tab means
// "that one", the same as Enter, minus the launching.
let filled = match self
.launch_cwd_pick
.and_then(|i| self.launch_cwd_hits.get(i))
{
Some(dir) => format!("{}/", crate::util::tildify(&dir.to_string_lossy())),
None => match dirs::complete(&self.launch_cwd_input, &self.launch_cwd_hits) {
Some(filled) => filled,
None => return,
},
};
if filled.chars().count() > input::MAX_PATH_INPUT {
return;
}
self.launch_cwd_input = filled;
self.launch_cwd_bad = false;
self.launch_cwd_suggest();
self.needs_redraw = true;
}
/// Take the highlighted suggestion, or what is typed if there is none.
///
/// A suggestion is a directory this code listed off the disk moments ago, so
/// it is taken without the check the typed path gets — and it is taken by
/// filling the field with it first, so that a suggestion which has since
/// been deleted is refused in the field like anything else.
pub(super) fn take_launch_cwd(&mut self) {
if let Some(dir) = self
.launch_cwd_pick
.and_then(|i| self.launch_cwd_hits.get(i))
{
self.launch_cwd_input = crate::util::tildify(&dir.to_string_lossy());
}
self.accept_launch_cwd();
}
/// Take the typed directory, if it names one.
///
/// Checked here rather than at launch. A path that does not exist fails
/// somewhere inside the shim with a message about spawning, by which point
/// the launcher is gone and there is nothing left to correct.
pub(super) fn accept_launch_cwd(&mut self) {
let typed = self.launch_cwd_input.trim();
// Empty means "wherever cctop was started", which is what the launcher
// offers by default and what the footer calls "this directory".
let taken = match typed.is_empty() {
true => None,
false => {
let path = std::path::PathBuf::from(crate::util::untildify(typed));
if !path.is_dir() {
self.launch_cwd_bad = true;
return;
}
Some(path)
}
};
// Cleared on the way out, not only on the way in: a path corrected
// after a refusal would otherwise carry the mark back to a field that
// now holds something perfectly good.
self.launch_cwd_bad = false;
self.launch_cwd = taken;
self.mode = Mode::Launch;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{key, session, test_app};
use ratatui::crossterm::event::KeyCode;
use std::sync::mpsc::channel;
/// The suggestions are part of the footer, not part of the list: a short
/// terminal has to lose choices before it loses the paths being offered,
/// because they are what the field is being typed against.
#[test]
fn the_directory_suggestions_stay_on_screen_under_a_long_list() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let root = tempfile::tempdir().expect("tempdir");
let project = root.path().join("chosen-project");
std::fs::create_dir(&project).expect("mkdir");
let mut app = test_app();
app.launch_offer = (0..20)
.map(|i| tabs::Choice::Start(vec![format!("agent-{i}")]))
.collect();
app.launch_cwd_known = vec![project.clone()];
app.launch_cwd_input = String::new();
app.launch_cwd_suggest();
app.launch_cwd_pick = Some(0);
app.mode = Mode::LaunchCwd;
let (cols, rows) = (80u16, 14u16);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
let mut layout = render::Layout::default();
terminal
.draw(|frame| layout = render::draw(frame, &mut app))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let text: String = (0..rows)
.map(|y| {
(0..cols)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect();
assert!(
text.contains("chosen-project"),
"the suggestion is not drawn"
);
assert!(text.contains("Tab fill in"), "the key that fills it in");
// And it is clickable where it was drawn, rather than on a row the
// choices above it also claim.
let (row, i) = *layout
.launch_cwd_rows
.first()
.expect("no clickable suggestion");
assert_eq!(i, 0);
assert!(row < rows, "the suggestion is drawn off screen");
assert!(
!layout.launch_rows.iter().any(|(y, _)| *y == row),
"a choice and a suggestion share row {row}"
);
}
/// The launcher's directory is a field, not a caption. A `claude` opened on
/// the wrong project reads its way into the wrong repository before anyone
/// notices, and until now the only way to change it was to restart cctop
/// somewhere else.
#[test]
fn the_launchers_directory_can_be_typed_and_is_checked_before_it_is_taken() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = App::new(Plan::Retail, channel().0);
app.launch_cwd = Some(dir.path().to_path_buf());
// Opens prefilled with what it would have used, so nothing looks lost.
app.edit_launch_cwd();
assert_eq!(app.mode, Mode::LaunchCwd);
assert_eq!(
app.launch_cwd_input,
crate::util::tildify(&dir.path().to_string_lossy())
);
// A directory that is not one is refused where it was typed, and the
// field stays open. Failing at launch instead would report it from
// inside the shim, after the launcher had gone.
app.launch_cwd_input = dir.path().join("nope").to_string_lossy().into_owned();
app.accept_launch_cwd();
assert!(app.launch_cwd_bad);
assert_eq!(app.mode, Mode::LaunchCwd, "the field stays open");
assert_eq!(app.launch_cwd.as_deref(), Some(dir.path()), "unchanged");
// A real one is taken.
let sub = dir.path().join("work");
std::fs::create_dir(&sub).expect("mkdir");
app.launch_cwd_input = sub.to_string_lossy().into_owned();
app.accept_launch_cwd();
assert!(!app.launch_cwd_bad);
assert_eq!(app.mode, Mode::Launch);
assert_eq!(app.launch_cwd.as_deref(), Some(sub.as_path()));
// Empty means where cctop was started, which is what the footer calls
// "this directory" — not an error, and not the previous value.
app.edit_launch_cwd();
app.launch_cwd_input = " ".into();
app.accept_launch_cwd();
assert_eq!(app.launch_cwd, None);
assert_eq!(app.mode, Mode::Launch);
}
/// The field is the only place in cctop where a path has to be produced from
/// memory, so it offers what it can see: the projects agents have run in
/// before anything is typed, the directories under whatever is typed after.
#[test]
fn the_directory_field_offers_paths_instead_of_asking_you_to_recall_them() {
let root = tempfile::tempdir().expect("tempdir");
let project = root.path().join("api");
let deep = project.join("service");
std::fs::create_dir_all(&deep).expect("mkdir");
let gone = root.path().join("deleted");
let mut app = test_app();
// One project that still exists and one that does not: a recorded
// directory outlives the checkout it named.
app.sessions = vec![
session("a", true, &project.to_string_lossy()),
session("b", false, &gone.to_string_lossy()),
];
app.launch_root = None;
app.edit_launch_cwd();
// Nothing typed, and the project is already on screen. The one that has
// since been deleted is not, because taking it could only fail.
assert_eq!(app.launch_cwd_hits, vec![project.clone()]);
assert!(!app.launch_cwd_hits.contains(&gone));
// The arrows move into the list and back out of it. Out, because the
// field is still what is being typed in.
app.on_key(key(KeyCode::Down));
assert_eq!(app.launch_cwd_pick, Some(0));
app.on_key(key(KeyCode::Up));
assert_eq!(app.launch_cwd_pick, None);
// Tab on the highlighted project fills it in and then shows what is
// inside it, which is how a path nobody remembers gets walked to.
app.on_key(key(KeyCode::Down));
app.on_key(key(KeyCode::Tab));
assert_eq!(
app.launch_cwd_input,
format!("{}/", project.to_string_lossy())
);
assert_eq!(app.launch_cwd_hits, vec![deep.clone()]);
assert_eq!(app.launch_cwd_pick, None, "a fresh list picks nothing");
// Enter on a suggestion takes it without the refusal a mistyped path
// gets — it was listed off the disk moments ago.
app.on_key(key(KeyCode::Down));
app.on_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Launch);
assert!(!app.launch_cwd_bad);
assert_eq!(app.launch_cwd.as_deref(), Some(deep.as_path()));
// Typing still decides on its own: a path with no match offers nothing
// and is refused where it was typed, exactly as before.
app.edit_launch_cwd();
app.launch_cwd_input = root.path().join("nowhere").to_string_lossy().into_owned();
app.launch_cwd_suggest();
assert!(app.launch_cwd_hits.is_empty());
app.on_key(key(KeyCode::Enter));
assert!(app.launch_cwd_bad);
assert_eq!(app.mode, Mode::LaunchCwd);
}
/// Esc has to leave the launch as it was found, or it becomes a way to lose
/// the setting you opened the field to change.
#[test]
fn cancelling_the_directory_field_keeps_the_old_one() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = App::new(Plan::Retail, channel().0);
app.launch_cwd = Some(dir.path().to_path_buf());
app.edit_launch_cwd();
app.launch_cwd_input = "/somewhere/else".into();
app.on_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Launch);
assert_eq!(app.launch_cwd.as_deref(), Some(dir.path()));
}
}