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
3 changes: 1 addition & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
"WebFetch",
"Bash(*)",
"Write(**)",
"Edit(**)",
"MultiEdit(**)"
"Edit(**)"
]
}
}
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The diff for a selected file shows the combined working-tree-with-index changes.

**Commit log view** (`<prefix> l`) — tig-like commit list on the left, full commit diff on the right. Commits ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill into its individual files; `Esc` to go back. The list auto-refreshes when the workdir HEAD changes (commits made in the terminal pane, amends, force-pushes, branch switches). History loads one page at a time — initial entry fetches `commit_log_page_size` commits and additional pages stream in on a background thread as the selection approaches the loaded tail, so deep histories stay responsive. Toggling while a terminal or diff pane is zoomed exits the zoom and focuses the list, so the view switch is always visible.

**Tree view** (`<prefix> b`) — a read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. `j`/`k` move the cursor, `→`/`Enter` expand a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. Press `/` while the tree is focused for a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. Focus the file preview with `<prefix> 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. `.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions.
**Tree view** (`<prefix> b`) — a read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. `Enter` on a file row opens it in the preview pane and zooms that pane fullscreen (`Enter` again, or `<prefix> f`, exits the zoom); on a directory row it does nothing. Press `/` while the tree is focused for a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. Focus the file preview with `<prefix> 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. `.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions.

**Notice row** — a one-row strip just above the hint bar shows the repo path (home-relative, e.g. `~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when the branch tracks an upstream. When something fails — a git snapshot, a diff load, a terminal pane, or a repo path you typed that doesn't exist — the message takes over this row in red until the problem is resolved or you act on the app again. A rejected repo path therefore appears directly above the input you're correcting. The repo dialog's completion candidates share this row (dimmed, and a notice outranks them), so a list too long for one line ends in `+N more`.

Expand All @@ -158,7 +158,7 @@ The diff for a selected file shows the combined working-tree-with-index changes.
| `Enter` | Take the selected path into the field and return to it — this does **not** open the repo. Press `Enter` again in the field for that, or keep refining the path with `Tab` first |
| `Esc` | Leave the browser, keeping the text it started from. A second `Esc` cancels the dialog |

Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands (unlike the file-tree view, where `Enter` expands too). Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only.
Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands — matching the file-tree view, where `Enter` opens a file rather than expanding. Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only.

## Keyboard shortcuts

Expand Down Expand Up @@ -236,7 +236,7 @@ through to the terminal program.
| `<prefix> f` | Zoom the list pane to full screen (toggle) |
| `/` | Incremental search (status: paths; log: commit summaries; drill-down: paths; tree: recursive filenames) |
| `Esc` | Clear filter, then exit drill-down (log), then cancel search bar |
| `Enter` | Confirm filter (keeps query) or drill into commit's file list (log view) |
| `Enter` | Confirm filter (keeps query), drill into commit's file list (log view), or open the selected file fullscreen (tree view) |

### Diff viewer (right panel)

Expand All @@ -251,6 +251,7 @@ through to the terminal program.
| `s` | Toggle between the unified diff and a side-by-side split view (falls back to unified when the pane is too narrow) |
| — | **Line numbers** are always shown in a pinned gutter. The unified view shows both sides (old, new) — an added line leaves the old column blank, a removed line leaves the new one blank. The split view numbers each half with the side it shows, and the file view (`v`) numbers the file itself. The gutter stays in place while `←`/`→` scroll the code |
| `<prefix> f` | Zoom the diff/file pane to full screen (toggle) |
| `Enter` | Zoom the diff/file pane to full screen (toggle) — same as `<prefix> f` |
| `/` | Open search (works in both diff and file preview, including tree mode) |
| `n` / `N` | Next / previous search match |
| `Esc` | Clear search |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ repo 헤더다. 플로팅 팝업을 쓰지 않은 이유는 `src/ui/`에 오버
거기다 — 배울 키 없이 도달하는 경로를 하나 남긴다.
- **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는
지점은 필드의 `Enter` 한 곳뿐이다. 그래서 `Enter`의 의미가 두 surface에서
갈리고, 브라우저에서는 확장이 `→` 전용이다(트리 뷰는 `Enter`도 확장한다).
갈리고, 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` 전용이며 `Enter`는 파일 열기다).
- **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래
깊은 row를 drain — 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다.
- **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와
Expand Down
8 changes: 7 additions & 1 deletion src/app/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,13 @@ impl App {
}

pub fn toggle_diff_fullscreen(&mut self) {
self.diff.fullscreen = !self.diff.fullscreen;
self.set_diff_fullscreen(!self.diff.fullscreen);
}

// Entering diff fullscreen has to clear the two competing fullscreens;
// callers that force it on (Tree `Enter`) share that rule with the toggle.
pub(crate) fn set_diff_fullscreen(&mut self, on: bool) {
self.diff.fullscreen = on;
if self.diff.fullscreen {
self.focus = Focus::DiffViewer;
self.terminal.fullscreen = TerminalFullscreen::Off;
Expand Down
1 change: 1 addition & 0 deletions src/app/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ mod strip_escape;
mod terminal_init;
mod terminal_scrollback;
mod tree;
mod tree_open;
mod tree_session;
mod tree_watcher;

Expand Down
69 changes: 69 additions & 0 deletions src/app/tests/tree_open.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Tree `Enter` (`tree_open_selected`): opens a file fullscreen, ignores dirs.

use super::tree::{app_on, make_tree_repo, tree_index_of};
use super::*;

#[test]
fn tree_open_on_directory_row_does_not_change_expansion() {
let (dir, path) = make_tree_repo();
let mut app = app_on(&path);
app.enter_tree_mode();
app.tree_view.selected = tree_index_of(&app, "src");

app.tree_open_selected();
assert!(
!app.tree_view.expanded.contains("src"),
"Enter must not expand a directory"
);
assert!(
!app.diff.fullscreen,
"a directory row must not zoom the pane"
);

// Already expanded: Enter must not collapse it either.
app.tree_expand();
app.tree_view.selected = tree_index_of(&app, "src");
app.tree_open_selected();
assert!(
app.tree_view.expanded.contains("src"),
"Enter must not collapse a directory"
);
drop(dir);
}

#[test]
fn tree_open_on_file_row_loads_file_view_and_goes_fullscreen() {
let (dir, path) = make_tree_repo();
let mut app = app_on(&path);
app.enter_tree_mode();
app.tree_view.selected = tree_index_of(&app, "README.md");

app.tree_open_selected();

assert_eq!(app.diff.view, DiffPaneView::File);
assert_eq!(
app.diff.file_view.key,
Some(FileViewKey::Status("README.md".to_string()))
);
assert_eq!(app.diff.file_view.content, "# hi\n");
assert!(app.diff.fullscreen);
assert_eq!(app.focus, Focus::DiffViewer);
drop(dir);
}

#[test]
fn tree_open_on_file_row_clears_competing_fullscreens() {
let (dir, path) = make_tree_repo();
let mut app = app_on(&path);
app.enter_tree_mode();
app.list_fullscreen = true;
app.terminal.fullscreen = TerminalFullscreen::Grid;
app.tree_view.selected = tree_index_of(&app, "README.md");

app.tree_open_selected();

assert!(app.diff.fullscreen);
assert!(!app.list_fullscreen);
assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off);
drop(dir);
}
17 changes: 7 additions & 10 deletions src/app/tree_nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,19 @@ impl App {
}
}

// Enter toggles a directory open/closed; on a file row it (re)loads the
// preview, mirroring selection behaviour.
pub fn tree_toggle(&mut self) {
// Enter opens the selected file: load it into the file view and zoom the
// diff pane so reading is the whole screen. Expansion stays on `→`/`←`, so
// a directory row does nothing here.
pub fn tree_open_selected(&mut self) {
let selected = self.tree_view.selected;
let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else {
return;
};
if row.is_dir {
if self.tree_view.expanded.contains(&row.path) {
self.tree_collapse();
} else {
self.tree_expand();
}
} else {
self.preview_tree_selected();
return;
}
self.preview_tree_selected();
self.set_diff_fullscreen(true);
}

// Walk the whole tree once to build the search index, then keep showing
Expand Down
13 changes: 8 additions & 5 deletions src/application/input/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,11 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) {
KeyCode::Enter if app.mode == ViewMode::Log && !app.log_view.drill_down => {
app.log_drill_in()
}
// Tree navigation: Enter toggles a directory (or re-previews a
// file), Right expands, Left collapses / steps to the parent. These
// guarded arms shadow the generic Left/Right horizontal-scroll arms
// below while in Tree mode.
KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_toggle(),
// Tree navigation: Enter opens the selected file fullscreen (no-op
// on a directory), Right expands, Left collapses / steps to the
// parent. These guarded arms shadow the generic Left/Right
// horizontal-scroll arms below while in Tree mode.
KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_open_selected(),
KeyCode::Right if app.mode == ViewMode::Tree => app.tree_expand(),
KeyCode::Left if app.mode == ViewMode::Tree => app.tree_collapse(),
// Log search Esc precedence sits ahead of `log_drill_out` so the
Expand Down Expand Up @@ -235,6 +235,9 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) {
_ => {}
},
Focus::DiffViewer => match key.code {
// Counterpart to Tree `Enter`: the same key that zoomed the pane in
// zooms it back out.
KeyCode::Enter => app.toggle_diff_fullscreen(),
_ if matches_text_command(key, 'v') => app.toggle_diff_file_view(),
_ if matches_text_command(key, 's') => app.toggle_diff_split_view(),
_ if matches_text_command(key, 'w') => app.toggle_diff_wrap(),
Expand Down
19 changes: 19 additions & 0 deletions src/application/tests/enter_fullscreen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! `Enter` key routing for the diff pane zoom.

use super::helpers::*;
use crate::app::Focus;
use crate::app::tests::app_with_files;
use crate::application::input::dispatch::handle_key;
use crossterm::event::{KeyCode, KeyModifiers};

#[test]
fn enter_in_diff_viewer_toggles_diff_fullscreen() {
let mut app = app_with_files(vec!["a.rs"]);
app.focus = Focus::DiffViewer;

let _ = handle_key(&mut app, press(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.diff.fullscreen, "Enter must zoom the diff pane");

let _ = handle_key(&mut app, press(KeyCode::Enter, KeyModifiers::NONE));
assert!(!app.diff.fullscreen, "a second Enter must exit the zoom");
}
1 change: 1 addition & 0 deletions src/application/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod accent;
mod enter_fullscreen;
mod helpers;
mod mouse;
mod mouse_clicks;
Expand Down
21 changes: 16 additions & 5 deletions src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@ pub fn open_repo(path: &str) -> Repository {
Repository::discover(path).expect("discover test repo")
}

/// Git exports `GIT_DIR`/`GIT_INDEX_FILE` as *relative* paths to its hooks, so a
/// suite run from `.githooks/pre-commit` would resolve them inside the temp repo
/// and fail. Strip them so a test means the same thing wherever it runs.
const LEAKED_GIT_ENV: [&str; 5] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_PREFIX",
];

pub fn run_git(repo_path: &str, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.unwrap();
let mut command = Command::new("git");
for key in LEAKED_GIT_ENV {
command.env_remove(key);
}
let output = command.args(args).current_dir(repo_path).output().unwrap();
assert!(
output.status.success(),
"git {} failed: {}",
Expand Down
4 changes: 2 additions & 2 deletions src/ui/hint_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str {
" <prefix> f: exit zoom | j/k: navigate | /: search | <prefix> l: log view | <prefix> b: tree view | <prefix> q: detach"
}
ViewMode::Tree => {
" <prefix> f: exit zoom | j/k: navigate | /: search | →/enter: expand | ←: collapse | <prefix> b: status view | <prefix> l: log view | <prefix> q: detach"
" <prefix> f: exit zoom | j/k: navigate | /: search | →: expand | ←: collapse | enter: open file | <prefix> b: status view | <prefix> l: log view | <prefix> q: detach"
}
};
return hint;
Expand Down Expand Up @@ -140,7 +140,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str {
" shift+←/→: cycle | j/k: navigate | /: search | <prefix> t: new pane | <prefix> f: fullscreen | <prefix> l: log view | <prefix> b: tree view | <prefix> o: open project | <prefix> q: detach"
}
ViewMode::Tree => {
" shift+←/→: cycle | j/k: navigate | /: search | →/enter: expand | ←: collapse | <prefix> b: status view | <prefix> l: log view | <prefix> q: detach"
" shift+←/→: cycle | j/k: navigate | /: search | →: expand | ←: collapse | enter: open file | <prefix> b: status view | <prefix> l: log view | <prefix> q: detach"
}
},
Focus::DiffViewer => {
Expand Down
4 changes: 3 additions & 1 deletion src/web/viewer/server/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ pub(super) fn server_with(
/// Send a raw request and return the full response text.
pub(super) fn request(addr: SocketAddr, raw: &str) -> String {
let mut stream = TcpStream::connect(addr).unwrap();
// Only a hang guard, never a latency assertion: /login verifies an Argon2
// hash, so a loaded machine running the suite in parallel needs the slack.
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.set_read_timeout(Some(Duration::from_secs(30)))
.unwrap();
stream.write_all(raw.as_bytes()).unwrap();
let mut buf = Vec::new();
Expand Down