Skip to content

Commit 534e13f

Browse files
authored
Add filtered-directory defaults and directory suggestions for new sessions (#23)
* Add directory session shortcuts * Format directory suggestion changes * Clarify suggestion cycling wraparound * Drop README updates * Show directory suggestions as selectable list --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent aef3aee commit 534e13f

3 files changed

Lines changed: 284 additions & 16 deletions

File tree

src/app.rs

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ pub struct App {
105105
pub input_buffer: String,
106106
pub session_filter: SessionFilter,
107107
pub directory_filter: String,
108+
directory_filter_history: Vec<String>,
109+
directory_suggestion_cursor: Option<usize>,
108110
pub detail_scroll: usize,
109111
pub help_scroll: usize,
110112
pub should_quit: bool,
@@ -147,6 +149,8 @@ impl App {
147149
input_buffer: String::new(),
148150
session_filter: SessionFilter::All,
149151
directory_filter: String::new(),
152+
directory_filter_history: Vec::new(),
153+
directory_suggestion_cursor: None,
150154
detail_scroll: 0,
151155
help_scroll: 0,
152156
should_quit: false,
@@ -351,11 +355,15 @@ impl App {
351355
pub fn begin_directory_filter(&mut self) {
352356
self.mode = Mode::DirectoryFilter;
353357
self.input_buffer = self.directory_filter.clone();
358+
self.sync_directory_suggestion_cursor();
354359
}
355360

356361
pub fn confirm_directory_filter(&mut self) {
357-
self.directory_filter = self.input_buffer.trim().to_string();
362+
let directory_filter = self.input_buffer.trim().to_string();
363+
self.remember_directory_filter(&directory_filter);
364+
self.directory_filter = directory_filter;
358365
self.input_buffer.clear();
366+
self.directory_suggestion_cursor = None;
359367
self.mode = Mode::Normal;
360368
self.apply_session_filters();
361369
}
@@ -374,7 +382,8 @@ impl App {
374382
/// Open the prompt to launch a new copilot session.
375383
pub fn begin_new_session(&mut self) {
376384
self.mode = Mode::NewSessionDir;
377-
self.input_buffer = self.default_new_session_dir().to_string_lossy().to_string();
385+
self.input_buffer = self.default_new_session_dir();
386+
self.sync_directory_suggestion_cursor();
378387
}
379388

380389
/// Confirm the new session directory prompt and queue a launch.
@@ -392,13 +401,58 @@ impl App {
392401
PathBuf::from(&raw)
393402
};
394403
self.input_buffer.clear();
404+
self.directory_suggestion_cursor = None;
395405
self.mode = Mode::Normal;
396406
self.pending_action = PendingAction::LaunchNew { dir };
397407
}
398408

399409
pub fn cancel_input(&mut self) {
400410
self.mode = Mode::Normal;
401411
self.input_buffer.clear();
412+
self.directory_suggestion_cursor = None;
413+
}
414+
415+
pub fn select_next_directory_suggestion(&mut self) {
416+
self.select_directory_suggestion(false);
417+
}
418+
419+
pub fn select_previous_directory_suggestion(&mut self) {
420+
self.select_directory_suggestion(true);
421+
}
422+
423+
pub fn sync_directory_suggestion_cursor(&mut self) {
424+
let current = self.input_buffer.trim();
425+
self.directory_suggestion_cursor = self
426+
.directory_suggestions()
427+
.iter()
428+
.position(|suggestion| suggestion == current);
429+
}
430+
431+
fn select_directory_suggestion(&mut self, reverse: bool) {
432+
let suggestions = self.directory_suggestions();
433+
if suggestions.is_empty() {
434+
return;
435+
}
436+
437+
let current_index = self
438+
.directory_suggestion_cursor
439+
.filter(|index| *index < suggestions.len())
440+
.or_else(|| {
441+
let current = self.input_buffer.trim();
442+
suggestions
443+
.iter()
444+
.position(|suggestion| suggestion == current)
445+
});
446+
let next_index = match current_index {
447+
Some(0) if reverse => suggestions.len() - 1,
448+
Some(index) if reverse => index - 1,
449+
Some(index) => (index + 1) % suggestions.len(),
450+
None if reverse => suggestions.len() - 1,
451+
None => 0,
452+
};
453+
454+
self.input_buffer = suggestions[next_index].clone();
455+
self.directory_suggestion_cursor = Some(next_index);
402456
}
403457

404458
/// Queue opening an embedded terminal for the session under the cursor.
@@ -474,11 +528,55 @@ impl App {
474528
self.flat_list.get(self.cursor).copied()
475529
}
476530

477-
fn default_new_session_dir(&self) -> PathBuf {
531+
fn default_new_session_dir(&self) -> String {
532+
let directory_filter = self.directory_filter.trim();
533+
if !directory_filter.is_empty() {
534+
return directory_filter.to_string();
535+
}
536+
478537
self.session_at_cursor()
479538
.or(self.selected_session)
480-
.map(|idx| self.sessions[idx].cwd.clone())
481-
.unwrap_or_else(|| self.launch_dir.clone())
539+
.map(|idx| self.sessions[idx].cwd.to_string_lossy().to_string())
540+
.unwrap_or_else(|| self.launch_dir.to_string_lossy().to_string())
541+
}
542+
543+
fn remember_directory_filter(&mut self, directory_filter: &str) {
544+
let directory_filter = directory_filter.trim();
545+
if directory_filter.is_empty() {
546+
return;
547+
}
548+
549+
self.directory_filter_history
550+
.retain(|entry| entry != directory_filter);
551+
self.directory_filter_history
552+
.insert(0, directory_filter.to_string());
553+
self.directory_filter_history.truncate(20);
554+
}
555+
556+
pub fn directory_suggestions(&self) -> Vec<String> {
557+
let mut suggestions = Vec::new();
558+
push_unique_directory_suggestion(&mut suggestions, self.directory_filter.trim());
559+
560+
for entry in &self.directory_filter_history {
561+
push_unique_directory_suggestion(&mut suggestions, entry);
562+
}
563+
564+
let mut local_sessions: Vec<_> = self
565+
.sessions
566+
.iter()
567+
.filter(|session| session.source == SessionSource::Local)
568+
.collect();
569+
local_sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
570+
for session in local_sessions {
571+
push_unique_directory_suggestion(&mut suggestions, &session.cwd.to_string_lossy());
572+
}
573+
574+
suggestions
575+
}
576+
577+
pub fn directory_suggestion_cursor(&self) -> Option<usize> {
578+
self.directory_suggestion_cursor
579+
.filter(|index| *index < self.directory_suggestions().len())
482580
}
483581

484582
fn take_waiting_notification(&mut self) -> bool {
@@ -657,6 +755,13 @@ fn is_remote_session(session: &CopilotSession) -> bool {
657755
session.source == SessionSource::Remote
658756
}
659757

758+
fn push_unique_directory_suggestion(suggestions: &mut Vec<String>, value: &str) {
759+
let value = value.trim();
760+
if !value.is_empty() && !suggestions.iter().any(|suggestion| suggestion == value) {
761+
suggestions.push(value.to_string());
762+
}
763+
}
764+
660765
#[cfg(test)]
661766
mod tests {
662767
use super::*;
@@ -678,6 +783,8 @@ mod tests {
678783
input_buffer: String::new(),
679784
session_filter: SessionFilter::All,
680785
directory_filter: String::new(),
786+
directory_filter_history: Vec::new(),
787+
directory_suggestion_cursor: None,
681788
detail_scroll: 0,
682789
help_scroll: 0,
683790
should_quit: false,
@@ -866,6 +973,93 @@ mod tests {
866973
assert!(build_flat_list(&sessions, SessionFilter::Remote, "/tmp/react/src").is_empty());
867974
}
868975

976+
#[test]
977+
fn new_session_defaults_to_active_directory_filter() {
978+
let mut app = app_with_sessions(vec![session_with_details(
979+
"local",
980+
SessionSource::Local,
981+
"/work/selected",
982+
SessionStatus::Idle,
983+
None,
984+
None,
985+
)]);
986+
app.directory_filter = "/work/filtered".to_string();
987+
988+
app.begin_new_session();
989+
990+
assert_eq!(app.input_buffer, "/work/filtered");
991+
}
992+
993+
#[test]
994+
fn directory_suggestions_are_sorted_by_mru() {
995+
let mut app = app_with_sessions(vec![
996+
session_with_details(
997+
"older-local",
998+
SessionSource::Local,
999+
"/work/older-session",
1000+
SessionStatus::Idle,
1001+
None,
1002+
None,
1003+
),
1004+
session_with_details(
1005+
"recent-local",
1006+
SessionSource::Local,
1007+
"/work/recent-session",
1008+
SessionStatus::Idle,
1009+
None,
1010+
None,
1011+
),
1012+
session_with_details(
1013+
"remote",
1014+
SessionSource::Remote,
1015+
"owner/repo",
1016+
SessionStatus::Idle,
1017+
None,
1018+
Some("owner/repo"),
1019+
),
1020+
]);
1021+
app.sessions[0].updated_at = Utc::now() - chrono::Duration::minutes(10);
1022+
app.sessions[1].updated_at = Utc::now();
1023+
1024+
app.input_buffer = "/work/older-filter".to_string();
1025+
app.confirm_directory_filter();
1026+
app.input_buffer = "/work/recent-filter".to_string();
1027+
app.confirm_directory_filter();
1028+
1029+
assert_eq!(
1030+
app.directory_suggestions(),
1031+
vec![
1032+
"/work/recent-filter",
1033+
"/work/older-filter",
1034+
"/work/recent-session",
1035+
"/work/older-session"
1036+
]
1037+
);
1038+
}
1039+
1040+
#[test]
1041+
fn directory_suggestions_can_be_selected() {
1042+
let mut app = app_with_sessions(vec![session_with_details(
1043+
"local",
1044+
SessionSource::Local,
1045+
"/work/session",
1046+
SessionStatus::Idle,
1047+
None,
1048+
None,
1049+
)]);
1050+
app.input_buffer = "/work/filtered".to_string();
1051+
app.confirm_directory_filter();
1052+
app.input_buffer.clear();
1053+
1054+
app.select_next_directory_suggestion();
1055+
assert_eq!(app.input_buffer, "/work/filtered");
1056+
assert_eq!(app.directory_suggestion_cursor(), Some(0));
1057+
1058+
app.select_next_directory_suggestion();
1059+
assert_eq!(app.input_buffer, "/work/session");
1060+
assert_eq!(app.directory_suggestion_cursor(), Some(1));
1061+
}
1062+
8691063
#[test]
8701064
fn moving_cursor_to_remote_task_does_not_load_log() {
8711065
let mut app = app_with_sessions(vec![

src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ fn resize_embedded_terminal(app: &mut App, term_size: ratatui::layout::Size) {
307307
fn handle_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) {
308308
match app.mode {
309309
Mode::Normal => handle_normal(app, key, modifiers),
310-
Mode::NewSessionDir => handle_input(app, key),
310+
Mode::NewSessionDir => handle_input(app, key, modifiers),
311311
Mode::DirectoryFilter => handle_directory_filter_input(app, key, modifiers),
312312
Mode::Terminal => handle_terminal(app, key, modifiers),
313313
Mode::Help => handle_help(app, key),
@@ -405,15 +405,23 @@ fn handle_help(app: &mut App, key: KeyCode) {
405405
}
406406
}
407407

408-
fn handle_input(app: &mut App, key: KeyCode) {
408+
fn handle_input(app: &mut App, key: KeyCode, modifiers: KeyModifiers) {
409409
match key {
410410
KeyCode::Enter => app.confirm_new_session(),
411411
KeyCode::Esc => app.cancel_input(),
412+
KeyCode::Down => app.select_next_directory_suggestion(),
413+
KeyCode::Up => app.select_previous_directory_suggestion(),
412414
KeyCode::Backspace => {
413415
app.input_buffer.pop();
416+
app.sync_directory_suggestion_cursor();
417+
}
418+
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
419+
app.input_buffer.clear();
420+
app.sync_directory_suggestion_cursor();
414421
}
415422
KeyCode::Char(c) => {
416423
app.input_buffer.push(c);
424+
app.sync_directory_suggestion_cursor();
417425
}
418426
_ => {}
419427
}
@@ -423,14 +431,19 @@ fn handle_directory_filter_input(app: &mut App, key: KeyCode, modifiers: KeyModi
423431
match key {
424432
KeyCode::Enter => app.confirm_directory_filter(),
425433
KeyCode::Esc => app.cancel_input(),
434+
KeyCode::Down => app.select_next_directory_suggestion(),
435+
KeyCode::Up => app.select_previous_directory_suggestion(),
426436
KeyCode::Backspace => {
427437
app.input_buffer.pop();
438+
app.sync_directory_suggestion_cursor();
428439
}
429440
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
430441
app.input_buffer.clear();
442+
app.sync_directory_suggestion_cursor();
431443
}
432444
KeyCode::Char(c) => {
433445
app.input_buffer.push(c);
446+
app.sync_directory_suggestion_cursor();
434447
}
435448
_ => {}
436449
}

0 commit comments

Comments
 (0)