Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ status는 저장소별 snapshot worker가 파일시스템 변화에 반응해
## Cross-cutting invariants

- **기하의 단일 출처**: 네 chrome 행은 `ui::chrome::chrome_rows`, visible pane cell은 `ui::terminal_tab::visible_pane_cells`만 계산한다. 렌더·resize·hit-test가 별도 산술을 갖지 않는다. 프로젝트 tab 행과 notice 행은 항상 존재해 PTY가 행 삽입/삭제로 resize되지 않는다.
- **입력 보호**: 기본 leader(`Ctrl+F`, 설정 가능) 뒤에만 앱 명령을 두고, 그 밖의 일반 키·단독 Ctrl은 active pane으로 그대로 보낸다. 앱이 합성하는 scroll/mouse report도 프로그램이 해당 mode를 켠 경우에만 보낸다.
- **입력 보호**: 기본 leader(`Ctrl+F`, 설정 가능) 뒤에만 앱 명령을 두고, 그 밖의 일반 키·단독 Ctrl은 active pane으로 그대로 보낸다. 예외는 leader 없이 예약한 소수의 chord뿐이며 — bare F-key, shift-only 방향키/PageUp·PageDown, `Ctrl+Shift`+좌우 — 정확한 modifier 집합으로만 매칭해 다른 조합은 pane으로 흘린다. 앱이 합성하는 scroll/mouse report도 프로그램이 해당 mode를 켠 경우에만 보낸다.
- **순서와 generation**: pane 생성·종료·resize·reorder는 backend/session event가 확정한다. daemon의 repository set은 watcher 한 곳만 전송하며, terminal output은 repo별 FIFO를 유지한다. 비동기 git 결과는 generation guard를 통과한 것만 적용한다.
- **경로 경계**: worktree 파일을 열 때는 `git::path::resolve_in_workdir`를, git object/pathspec만 다룰 때는 `validate_commit_path`를 사용한다. traversal·절대 경로·NUL·`.git` 변형을 거부하며 worktree 파일은 중간 component의 symlink도 따르지 않는다. 웹 route가 검증을 중복 구현하지 않고 공통 handler를 통과한다.
- **자원 상한과 오류**: frame, terminal queue, PTY/pane, 웹 연결·응답·목록·diff·검색에는 명시적 상한이 있다. 잘린 결과는 `truncated` 등으로 표시하고, malformed/truncated input과 외부 호출 실패는 성공처럼 기록하지 않는다.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ terminal connection은 읽기와 쓰기를 bounded polling으로 다루며, stal

`viewer.json`에 session accent·sidebar width·`upper_pct`와 project별 last view/maximize를 저장한다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다.

React 화면은 page 조립, reusable components, hooks, pure `lib`, API/wire 모듈을 분리한다. terminal WebSocket decode/encode는 한 경계에서 discriminated union으로 검증한다. 큰 diff/raw file은 viewport와 overscan만 DOM에 두고, 작은 파일은 native selection·find·accessibility를 보존한다. ErrorBoundary는 lazy chunk 실패가 전체 page unmount로 보이지 않게 하며, server build id와 content-hashed bundle을 비교해 stale page를 reload시킨다. DOM hook 테스트는 happy-dom, pure utility는 node 환경에서 실행한다. 빌드된 `viewer-ui/dist`는 runtime에 포함되므로 Node 없는 `cargo install`도 동작해야 한다.
키보드는 `document` capture 단계의 결정점 하나만 둔다. 두 listener는 키가 소비되었는지에 합의할 수 없어, 지는 쪽이 pane에 필요한 키를 먹거나 앱 명령을 escape sequence로 흘린다. 명령은 물리 키가 아니라 semantic action id로 registry에 두고 keyboard·help·버튼이 같은 표를 읽는다. TUI의 Rust key table을 복제하지 않고, 브라우저가 의미를 유지·재해석·미지원하는지를 registry가 기록한다. terminal panel의 명령은 page 아래에 있으므로 panel이 intent bus에 등록하며, 그 등록 여부가 availability의 단일 근거다. leader 선호는 client-local per-browser 값이므로 `viewer.json`이나 session이 아니라 browser storage에 둔다. React 화면은 page 조립, reusable components, hooks, pure `lib`, API/wire 모듈을 분리한다. terminal WebSocket decode/encode는 한 경계에서 discriminated union으로 검증한다. 큰 diff/raw file은 viewport와 overscan만 DOM에 두고, 작은 파일은 native selection·find·accessibility를 보존한다. ErrorBoundary는 lazy chunk 실패가 전체 page unmount로 보이지 않게 하며, server build id와 content-hashed bundle을 비교해 stale page를 reload시킨다. DOM hook 테스트는 happy-dom, pure utility는 node 환경에서 실행한다. 빌드된 `viewer-ui/dist`는 runtime에 포함되므로 Node 없는 `cargo install`도 동작해야 한다.

## Clone

Expand Down
27 changes: 27 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The prefix waits indefinitely for one follow-up. `Esc` or `Ctrl+C` cancels it. A
- `<prefix> f` toggles fullscreen for the focused list, diff, or terminal panel. Terminal fullscreen cycles through the grid and the active-pane zoom.
- `<prefix> o` opens the repository dialog.
- `<prefix> x` closes the active project tab.
- `<prefix> [` and `<prefix> ]` move the active project one slot towards the front or the back of the tab row. Tab order is shared with the browser and every other attached TUI. Neither wraps: the first tab does not move further forward and the last does not move further back.
- `<prefix> p` cycles the session accent: yellow, cyan, green, magenta, blue.
- `<prefix> u` reloads the configuration; see [Reloading](configuration.md#reloading).
- `<prefix> r` forces a full redraw.
Expand All @@ -27,10 +28,36 @@ The prefix waits indefinitely for one follow-up. `Esc` or `Ctrl+C` cancels it. A
## Global keys

- `F1`–`F10` switch project tabs 1–10. Modified function keys pass through to the terminal.
- `Ctrl+Shift+Left` / `Ctrl+Shift+Right` switch to the previous or next project, wrapping at both ends of the tab order. With one project open they do nothing.
- `Shift+Left` / `Shift+Right` cycle focus through the file list, diff viewer, and terminal.
- `Shift+Up` / `Shift+Down` scroll the active terminal three lines.
- `Shift+PageUp` / `Shift+PageDown` scroll the active terminal one page. Input remains live while scrolled.

## Web viewer

The browser binds the same commands, not the same physical keys. The keys above are free for the TUI to take because the terminal hands them over; a browser has already spent most of them, so the web viewer names user actions and binds each to whatever key it can actually receive. The tables below say which of the commands above survive unchanged, which keep the intent through a different mechanism, and which have no browser answer at all.

Its leader is also `Ctrl+F` by default, and it is consumed only where the page owns the keyboard — the app chrome and the terminal panel. In a text field, a dialog, or during IME composition the leader is never intercepted, so typing and native word selection are untouched. Pressing the leader twice sends one literal leader chord to the focused pane, as in the TUI. `Esc` or `Ctrl+C` cancels an armed leader, and it also clears itself on focus loss, on a dialog opening, on a project switch, and on a terminal reconnect, so the following key is never swallowed.

`Ctrl+F` is the browser's Find shortcut, so the viewer says so and lets the leader be rebound or switched off. Holding a shortcut down runs it once per press, not once per repeat.

### Same meaning as the TUI

The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u` does what the matching bullet under [Leader commands](#leader-commands) describes, using the same controls the buttons use. The focus keys `1`, `2`, and `3`–`9`, `0` address the list, the content pane, and terminal panes 1–8 with the same numbering. `Ctrl+Shift+Left` and `Ctrl+Shift+Right` switch projects exactly as they do in the TUI.

### Reinterpreted

- Moving a project within the tab row is a drag on the tab itself rather than a key. The order it writes is the same session-owned order the TUI's bracket keys move.
- The leader followed by `f` maximizes the focused panel and zooms the active terminal pane. A page cannot take the browser's chrome into fullscreen, and `F11` belongs to the browser, so the intent — give this panel the whole area — is kept and the mechanism is not.

### Not bound in the browser

- Redraw: the browser repaints the page itself, so there is no stale frame to force.
- Detach: closing a tab already leaves the session running. Signing out is a different, destructive action and is deliberately not on a key.
- `F1`–`F10` project selection: bare function keys are reserved by the browser and the OS. Use `Ctrl+Shift+Left` / `Ctrl+Shift+Right`, the project control, or the shortcut sheet instead.

`F5` and `F11` are never bound, and the viewer does not try to block them. Which chords a browser delivers to a page at all is the browser's decision, not the viewer's: the bindings above avoid the ones Chrome, Edge, Firefox, and Safari reserve on Windows, macOS, and Linux, and a chord a browser keeps for itself simply never arrives. The shortcut sheet lists every action with its key and marks the ones unavailable on the current screen, and every action also has a button or menu item. The one exception is `<prefix> s`, which arms a second step rather than running a command: no single control can stand for "then pick a pane", so the sheet lists it as text and dragging a pane does the same job.

## File list and commit list

- `Up` / `Down` and `k` / `j` move the selection; `PageUp` / `PageDown` move by a page-sized step.
Expand Down
4 changes: 2 additions & 2 deletions docs/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ The session can serve up to 10 repositories. Each repository is a project tab wi

Projects start without a terminal process unless a startup command is configured. Open the first shell with `<prefix> t`, or set `[terminal] auto_open = true` to create one automatically for projects without startup commands.

Open and close projects with `<prefix> o` and `<prefix> x`; switch among tabs with `F1`–`F10`. Opening a repository that is already open focuses the existing tab instead of creating a duplicate worktree view. The browser and every attached TUI share the project set, order, and active project.
Open and close projects with `<prefix> o` and `<prefix> x`; switch among tabs with `F1`–`F10` or step to the neighbouring tab with `Ctrl+Shift+Left` / `Ctrl+Shift+Right`, and move the active tab within the row with `<prefix> [` and `<prefix> ]` (see [Keyboard and mouse](keybindings.md#leader-commands)). Opening a repository that is already open focuses the existing tab instead of creating a duplicate worktree view. The browser and every attached TUI share the project set, order, and active project.

If tabs do not fit, the tab row folds inactive tabs behind an overflow marker. A background project shows an attention marker when its terminal reports unread activity; selecting that project acknowledges the marker, and later activity can raise it again.

Having no project open is valid. A new session starts there when no repositories are saved, and closing the last tab returns there. Use `<prefix> o` to open a repository.

Repository paths are normalized to their worktree root, so opening a subdirectory of an already open worktree focuses the existing project. The path dialog supports `~`, absolute paths, relative paths, completion, and a directory browser; see [Views → The repo dialog](views.md#the-repo-dialog).

Open tabs and the active tab are session-owned. Per-project selection, scroll, view mode, and fullscreen state are client view state; see [Session state](session-state.md).
Open tabs, their order, and the active tab are session-owned, so reordering from one client moves the tabs everywhere. Per-project selection, scroll, view mode, and fullscreen state are client view state; see [Session state](session-state.md).
2 changes: 1 addition & 1 deletion docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ State is stored under `~/.nightcrow/`; nightcrow does not write session state in
- `viewer.json` stores the session accent and browser layout preferences, including sidebar width, upper-panel split, per-project view, and maximized panel (up to 50 recent projects). Browser terminal panes and their live arrangement end with the session.
- `sessions` stores authenticated web-viewer tokens so browser logins can survive a daemon restart. Logout revokes a token server-side. Removing this file prevents tokens from being restored on the next restart; a running daemon keeps its in-memory tokens until they expire or are logged out.

The daemon owns the repository set and active tab. An attached TUI writes its own selection and view state when it detaches or the connection ends, without overwriting the tab list. Browser repository changes update the shared workspace. Closing every project before stopping writes an empty set, so the next session starts empty.
The daemon owns the repository set, the tab order, and the active tab. An attached TUI writes its own selection and view state when it detaches or the connection ends, without overwriting the tab list. Browser repository changes update the shared workspace. Closing every project before stopping writes an empty set, so the next session starts empty.

Corrupt or missing JSON state falls back to defaults. A repository that is no longer a directory is not started on the next daemon launch. Reopen it through the project dialog when it is available again.
4 changes: 2 additions & 2 deletions docs/web-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The header's project control opens an existing server-side directory, closes a p

Only `https://`, `http://`, `ssh://`, `git+ssh://`, and scp-style `user@host:path` remotes are accepted. Local paths, `file://`, `git://`, and `ext::` are refused. One clone runs at a time; it continues on the server if the page is closed or reloaded, and the page can resume polling it. A destination with an existing name is rejected.

Each project exposes `status`, `log`, and `tree` views, a diff/file content pane, and the same interactive terminal session as an attached TUI. The [Views](views.md) and [Keyboard and mouse](keybindings.md) guides describe the shared Git and input behavior. Browser view state (last tab/file, tree expansion, and maximized panel) is stored separately from TUI view state.
Each project exposes `status`, `log`, and `tree` views, a diff/file content pane, and the same interactive terminal session as an attached TUI. The [Views](views.md) and [Keyboard and mouse](keybindings.md) guides describe the shared Git and input behavior. The viewer binds the same commands to keys a browser can actually receive rather than to the TUI's physical keys; [Keyboard and mouse → Web viewer](keybindings.md#web-viewer) records which commands the browser keeps unchanged, reinterprets, or leaves unbound, and the in-app shortcut sheet lists them with their keys and marks the ones unavailable on the current screen. Browser view state (last tab/file, tree expansion, and maximized panel) is stored separately from TUI view state.

Markdown files render as formatted documents with highlighted fenced code. `.html` and `.htm` files can render in a sandbox that allows inline scripts but blocks cookies, session access, network connections, and external assets; use the raw-source toggle for inspection. The rendered page is a preview of a self-contained file, not a general website.

Expand All @@ -18,7 +18,7 @@ Drag the sidebar and upper-panel dividers to resize them; double-click a divider

The terminal toolbar can add a pane, show panes as a grid or tabs, maximize the terminal panel, claim sizing for this screen, and show the on-screen key bar. A project has up to 8 panes. Pane order and zoom are shared while the session runs; they are not restored after the session ends. A PTY has one size, so the client that most recently claims sizing determines the grid rendered by every client.

On phones and other narrow layouts, the bottom navigation switches among `Repo`, `Content`, and `Terminal`. Touch-dragging a terminal scrolls it; the key bar supplies Escape, Tab, arrows, and control keys when a soft keyboard cannot. Its `Ctrl` button is a latch for the next typed character. The keyboard-bar preference is stored in the browser, so it can be changed from the terminal toolbar.
On phones and other narrow layouts, the bottom navigation switches among `Repo`, `Content`, and `Terminal`. Touch-dragging a terminal scrolls it; the key bar supplies Escape, Tab, arrows, and control keys when a soft keyboard cannot. Its `Ctrl` button is a latch for the next typed character. The keyboard-bar preference is stored in the browser, so it can be changed from the terminal toolbar. The shortcut leader key is stored in the browser too, and can be rebound or switched off from the shortcut sheet.

Terminal programs may write to the clipboard through OSC 52; the text reaches the browser device viewing the pane. A program requesting clipboard contents is not answered. If the browser requires a user gesture to write, the viewer shows a Copy action.

Expand Down
24 changes: 24 additions & 0 deletions src/application/input/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ pub(crate) enum KeyOutcome {
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ProjectRequest {
Switch(usize),
/// Step one tab forward or backward, wrapping over tab order.
///
/// A direction rather than a resolved index because the sender holds one
/// project: the wrap needs the tab count and which tab is in front, and
/// both are only known where the tab list is.
Cycle {
forward: bool,
},
/// Move the active tab one slot within the strip, `forward` being away from
/// the front. A direction for the same reason `Cycle` is one, and separate
/// from it because this reorders the strip instead of stepping along it.
Move {
forward: bool,
},
Close,
Open(String),
OpenDialog,
Expand Down Expand Up @@ -139,6 +153,16 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option<KeyO
Action::OpenProject => Some(KeyOutcome::Project(ProjectRequest::OpenDialog)),
Action::CloseProject => Some(KeyOutcome::Project(ProjectRequest::Close)),
Action::SwitchProject(idx) => Some(KeyOutcome::Project(ProjectRequest::Switch(idx))),
Action::PrevProject => Some(KeyOutcome::Project(ProjectRequest::Cycle {
forward: false,
})),
Action::NextProject => Some(KeyOutcome::Project(ProjectRequest::Cycle { forward: true })),
Action::MoveProjectPrev => {
Some(KeyOutcome::Project(ProjectRequest::Move { forward: false }))
}
Action::MoveProjectNext => {
Some(KeyOutcome::Project(ProjectRequest::Move { forward: true }))
}
Action::ToggleFullscreen => {
match app.focus {
Focus::DiffViewer => app.toggle_diff_fullscreen(),
Expand Down
4 changes: 4 additions & 0 deletions src/application/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ pub(crate) mod event_loop;
pub(crate) mod input;
pub(crate) mod redraw;
pub(crate) mod session_link;
mod session_tabs;
pub(crate) mod splash;
pub(crate) mod terminal_guard;

#[cfg(test)]
#[path = "session_link_project_tests.rs"]
mod session_link_project_tests;
#[cfg(test)]
#[path = "session_terminals_tests.rs"]
mod session_terminals_tests;
Loading