Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)를 따른다.

Expand Down
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u`

## Repository dialog

`<prefix> 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).
`<prefix> 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

Expand Down
2 changes: 1 addition & 1 deletion docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ The header identifies the selected repository, branch, and tracked-branch ahead/

Open it with `<prefix> 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.
45 changes: 33 additions & 12 deletions src/application/input/repo_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
}
}
61 changes: 46 additions & 15 deletions src/application/tests/repo_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand All @@ -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"
);
}
2 changes: 1 addition & 1 deletion src/ui/path_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
2 changes: 1 addition & 1 deletion src/ui/repo_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};
Expand Down
2 changes: 1 addition & 1 deletion src/ui/tests/repo_picker_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Expand Down
10 changes: 6 additions & 4 deletions src/workspace/repo_picker.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading