From 1d9899a8589fe54d0f1d4f27509e79e76790a8f0 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 5 Sep 2026 13:26:45 +0900 Subject: [PATCH] fix(ui): open a repo in one Enter from the directory browser --- docs/architecture/ui.md | 2 +- docs/keybindings.md | 2 +- docs/views.md | 2 +- src/application/input/repo_dialog.rs | 45 ++++++++++++++------ src/application/tests/repo_dialog.rs | 61 +++++++++++++++++++++------- src/ui/path_tree.rs | 2 +- src/ui/repo_dialog.rs | 2 +- src/ui/tests/repo_picker_tests.rs | 2 +- src/workspace/repo_picker.rs | 10 +++-- 9 files changed, 91 insertions(+), 37 deletions(-) diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index cf9f47ad..d9341dc4 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -16,7 +16,7 @@ `Workspace`는 최대 `MAX_PROJECTS = 10`개의 `App`을 Vec와 active index로 관리한다. `App`은 한 저장소의 GitViewManager, pane 집합, 포커스·fullscreen·notice를 소유한다. active가 없을 수 있으므로 마지막 탭을 닫은 뒤에도 repo dialog와 quit만 동작한다. 같은 canonical worktree는 두 번 열지 않고 기존 탭으로 focus한다. 숨은 project의 terminal attention은 해당 TUI client에서만 읽음 처리한다. -repo open dialog는 Workspace 레벨에서 먼저 처리되므로 project가 0개여도 열 수 있다. 경로 입력은 셸을 실행하지 않고 `read_dir` 한 단계만으로 directory 후보를 완성한다. `~`와 상대 표기는 읽을 때만 확장하며 사용자가 입력한 텍스트는 그대로 보존한다. directory browser는 평면 row list로 확장/접기를 관리하고, 경로를 확정하는 것은 field의 `Enter` 한 곳이다. +repo open dialog는 Workspace 레벨에서 먼저 처리되므로 project가 0개여도 열 수 있다. 경로 입력은 셸을 실행하지 않고 `read_dir` 한 단계만으로 directory 후보를 완성한다. `~`와 상대 표기는 읽을 때만 확장하며 사용자가 입력한 텍스트는 그대로 보존한다. directory browser는 평면 row list로 확장/접기를 관리하며, 경로 확정은 field의 `confirm_repo_input` 한 곳에서 일어난다. browser의 `Enter`는 선택을 field에 넘긴 뒤 같은 입력에서 곧바로 확정까지 이어진다. TUI workspace state는 `~/.nightcrow/workspace.json`에 저장한다. 열린 project, active project와 project별 view를 기록하지만 저장소 내부에는 기록하지 않는다. 복원된 status 선택처럼 snapshot이 필요한 값만 pending으로 두며, background project의 queue는 매 tick 비우되 snapshot 적용은 active project에서 한다. worker join과 snapshot watch의 세부 규칙은 [session.md](session.md)를 따른다. diff --git a/docs/keybindings.md b/docs/keybindings.md index d80bbc6b..ed0fbf6e 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -84,7 +84,7 @@ The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u` ## Repository dialog -` o` opens a path field. `Tab` completes a directory, `Down` opens the directory browser, and `Enter` opens the selected path. `Esc` closes the browser first and the dialog second. Paths may be absolute, relative to the current directory, or begin with `~`; shell expansion, variables, globs, and files are not accepted. See [Views → The repo dialog](views.md#the-repo-dialog). +` o` opens a path field. `Tab` completes a directory, `Down` opens the directory browser, and `Enter` opens the selected path — in the browser it opens the highlighted directory, in the field it submits the typed text. `Esc` closes the browser first and the dialog second. Paths may be absolute, relative to the current directory, or begin with `~`; shell expansion, variables, globs, and files are not accepted. See [Views → The repo dialog](views.md#the-repo-dialog). ## Mouse diff --git a/docs/views.md b/docs/views.md index d53d8eb5..557886cc 100644 --- a/docs/views.md +++ b/docs/views.md @@ -26,4 +26,4 @@ The header identifies the selected repository, branch, and tracked-branch ahead/ Open it with ` o`. The field accepts an existing directory path, including absolute paths, paths relative to the current directory, and a leading `~`. It is a path field, not a shell: `cd`, environment variables, and globs are not expanded. An empty or nonexistent path is rejected and leaves the dialog open for correction. -`Tab` completes directory names. `Down` opens a keyboard-only directory browser; it lists visible directories, and `Right`/`Left` expand and collapse. `Enter` in the browser selects a directory into the field; `Enter` in the field submits it. `Esc` closes the browser first and then cancels the dialog. Opening a directory inside an existing worktree resolves to that worktree; a directory outside Git shows a repository error when its views load. +`Tab` completes directory names. `Down` opens a keyboard-only directory browser; it lists visible directories, and `Right`/`Left` expand and collapse. `Enter` in the browser opens the selected directory directly; `Enter` in the field submits its text. `Esc` closes the browser first and then cancels the dialog. Opening a directory inside an existing worktree resolves to that worktree; a directory outside Git shows a repository error when its views load. diff --git a/src/application/input/repo_dialog.rs b/src/application/input/repo_dialog.rs index b69b3b81..0a711965 100644 --- a/src/application/input/repo_dialog.rs +++ b/src/application/input/repo_dialog.rs @@ -9,8 +9,7 @@ pub(crate) fn handle_repo_input_key(ws: &mut Workspace, key: KeyEvent) -> KeyOut // The browser takes the keys while open; the field's text cannot change // until it hands a path back. if ws.repo_input.picker.is_some() { - handle_picker_key(ws, key); - return KeyOutcome::Continue; + return handle_picker_key(ws, key); } match key.code { KeyCode::Esc => ws.cancel_repo_input(), @@ -40,18 +39,40 @@ pub(crate) fn handle_repo_input_key(ws: &mut Workspace, key: KeyEvent) -> KeyOut KeyOutcome::Continue } -/// Enter selects here rather than opening: the field's Enter stays the single -/// place a repo is opened, so `→` alone expands. -fn handle_picker_key(ws: &mut Workspace, key: KeyEvent) { +/// Enter on a row opens it: selecting into the field and confirming there was +/// two keys for one gesture. `→` still expands, so descending without opening +/// stays possible. +fn handle_picker_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { match key.code { // One Esc leaves the browser with the field's text intact, a second // cancels the dialog. - KeyCode::Esc => ws.repo_input_close_browser(), - KeyCode::Enter => ws.repo_input_pick(), - KeyCode::Down | KeyCode::Char('j') => ws.repo_picker_move(true), - KeyCode::Up | KeyCode::Char('k') => ws.repo_picker_move(false), - KeyCode::Right => ws.repo_picker_expand(), - KeyCode::Left => ws.repo_picker_collapse(), - _ => {} + KeyCode::Esc => { + ws.repo_input_close_browser(); + KeyOutcome::Continue + } + KeyCode::Enter => { + ws.repo_input_pick(); + if let crate::workspace::RepoInputResult::Open(path) = ws.confirm_repo_input() { + return KeyOutcome::Project(ProjectRequest::Open(path)); + } + KeyOutcome::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + ws.repo_picker_move(true); + KeyOutcome::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + ws.repo_picker_move(false); + KeyOutcome::Continue + } + KeyCode::Right => { + ws.repo_picker_expand(); + KeyOutcome::Continue + } + KeyCode::Left => { + ws.repo_picker_collapse(); + KeyOutcome::Continue + } + _ => KeyOutcome::Continue, } } diff --git a/src/application/tests/repo_dialog.rs b/src/application/tests/repo_dialog.rs index 2af71eb1..3b211adb 100644 --- a/src/application/tests/repo_dialog.rs +++ b/src/application/tests/repo_dialog.rs @@ -3,7 +3,7 @@ //! field/browser contract in `workspace::tests::repo_picker_tests`. use super::helpers::*; -use crate::application::input::dispatch::{KeyOutcome, dispatch_key}; +use crate::application::input::dispatch::{KeyOutcome, ProjectRequest, dispatch_key}; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyModifiers}; use tempfile::TempDir; @@ -94,11 +94,11 @@ fn the_browser_takes_the_keys_the_field_would_have_had() { // In the field these would edit the buffer; here they drive the tree. send(&mut ws, KeyCode::Right); send(&mut ws, KeyCode::Down); - send(&mut ws, KeyCode::Enter); - assert!(ws.repo_input.picker.is_none(), "Enter selects and returns"); - assert_eq!(ws.repo_input.buf, format!("{text}/alpha/inner/")); - assert!(ws.repo_input.active, "selecting must not open the repo"); + // The tree nav keys never reach the buffer — only Enter does, and it goes + // straight to opening rather than editing the field. + assert!(ws.repo_input.picker.is_some(), "still browsing"); + assert_eq!(ws.repo_input.buf, text); } #[test] @@ -125,19 +125,50 @@ fn the_first_esc_leaves_the_browser_and_the_second_cancels_the_dialog() { assert!(!ws.repo_input.active); } +/// One Enter on a row asks the workspace to open that directory: pick and +/// confirm were two keys for one gesture, so the dialog closes behind the +/// request and the browser never returns to the field. #[test] -fn j_and_k_move_the_browser_without_reaching_the_field() { - let (_guard, mut ws, text) = dialog_on(&["alpha", "zeta"]); +fn enter_on_a_row_opens_that_directory_in_one_key() { + let (guard, mut ws, text) = dialog_on(&["alpha"]); send(&mut ws, KeyCode::Down); - send(&mut ws, KeyCode::Char('j')); - assert_eq!( - ws.repo_input.picker.as_ref().expect("open").selected(), - 1, - "`j` moves the cursor rather than typing a `j`" + // The one Enter both picks the row and asks the workspace to open it. + let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); + + let KeyOutcome::Project(ProjectRequest::Open(path)) = outcome else { + panic!("the Enter must ask the workspace to open: {outcome:?}"); + }; + let resolved = crate::git::resolve_repo_path(std::path::Path::new(&path)) + .to_string_lossy() + .to_string(); + let expected = crate::git::resolve_repo_path(std::path::Path::new(&format!( + "{}/alpha/", + text.trim_end_matches('/') + ))) + .to_string_lossy() + .to_string(); + assert_eq!(resolved, expected); + assert!( + !ws.repo_input.active, + "the dialog closed behind the request" ); - send(&mut ws, KeyCode::Char('k')); - send(&mut ws, KeyCode::Enter); + let _ = guard; +} - assert_eq!(ws.repo_input.buf, format!("{text}/alpha/")); +#[test] +fn enter_on_an_empty_directory_opens_the_root_itself() { + let (_guard, mut ws, _text) = dialog_on(&[]); + send(&mut ws, KeyCode::Down); + + let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); + + // No rows to select, so Enter hands the root itself to the field's confirm. + let KeyOutcome::Project(ProjectRequest::Open(_)) = outcome else { + panic!("the root must be openable: {outcome:?}"); + }; + assert!( + !ws.repo_input.active, + "the dialog closed behind the request" + ); } diff --git a/src/ui/path_tree.rs b/src/ui/path_tree.rs index 4e6d3810..8b59211a 100644 --- a/src/ui/path_tree.rs +++ b/src/ui/path_tree.rs @@ -20,7 +20,7 @@ pub(crate) fn render(frame: &mut Frame, tree: &PathTree, area: Rect, accent: Col let dim = Style::default().fg(Color::DarkGray); let (items, selected) = if tree.rows().is_empty() { // Nothing selectable, but the box must say why it is blank — an empty - // frame reads as a failure to load. Enter still picks the root itself. + // frame reads as a failure to load. Enter still opens the root itself. ( vec![ListItem::new(Line::from(Span::styled( " (no sub-directories)", diff --git a/src/ui/repo_dialog.rs b/src/ui/repo_dialog.rs index 07dae40b..df7ee43c 100644 --- a/src/ui/repo_dialog.rs +++ b/src/ui/repo_dialog.rs @@ -83,7 +83,7 @@ pub(crate) fn repo_dialog_hint_line<'a>( return line; } let legend = if repo_input.picker.is_some() { - " up/dn/jk: move | right: open | left: up | enter: select | esc: back" + " up/dn/jk: move | right: open | left: up | enter: open | esc: back" } else { " down: browse | tab: complete | enter: open | esc: cancel" }; diff --git a/src/ui/tests/repo_picker_tests.rs b/src/ui/tests/repo_picker_tests.rs index 6309a94a..19d50c58 100644 --- a/src/ui/tests/repo_picker_tests.rs +++ b/src/ui/tests/repo_picker_tests.rs @@ -82,7 +82,7 @@ fn the_browsers_own_keys_replace_the_fields_on_the_hint_row() { let line = repo_dialog_hint_line(None, &repo_input, 200).to_string(); - assert!(line.contains("enter: select"), "not `enter: open`: {line}"); + assert!(line.contains("enter: open"), "{line}"); assert!(line.contains("left: up"), "{line}"); assert!(!line.contains("down: browse"), "already browsing: {line}"); } diff --git a/src/workspace/repo_picker.rs b/src/workspace/repo_picker.rs index f11502f4..0af85ac5 100644 --- a/src/workspace/repo_picker.rs +++ b/src/workspace/repo_picker.rs @@ -1,5 +1,5 @@ -//! The repo dialog's directory browser. It only ever fills the field — opening -//! a repo stays the field's own Enter. +//! The repo dialog's directory browser. It hands a path to the field, whose +//! own Enter confirms it — the key handler may chain the two. use super::Workspace; use super::path_tree::PathTree; @@ -27,8 +27,10 @@ impl Workspace { self.repo_input.picker = None; } - /// Take the selection into the field. Enter means the same thing on every - /// row — navigating beyond what the tree shows is `←`'s job, not a row's. + /// Take the selection into the field, closing the browser. The caller may + /// confirm right after — Enter in the key handler opens in one gesture. + /// On every row it means the same thing: navigating beyond what the tree + /// shows is `←`'s job, not a row's. pub fn repo_input_pick(&mut self) { let Some(tree) = self.repo_input.picker.take() else { return;