diff --git a/.gitignore b/.gitignore index c146631e..7dc1c143 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,11 @@ /viewer-ui/tsconfig.tsbuildinfo # Agent tool scratch state /.atl/ +/.worktress/ + +# Checkout-local agent instructions +/AGENTS.local.md +/CLAUDE.local.md # Internal-only notes: not part of the published documentation. /docs/internal/ diff --git a/docs/architecture.md b/docs/architecture.md index b0765b1f..c6c049f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,10 +99,11 @@ src/ │ # logging.rs (file logger, rotation + retention), paths.rs │ # (tilde expansion), signals.rs (SIGINT/SIGTERM shutdown), │ # threading.rs (try_timed_join) -├── app.rs, app/ # App aggregate + InteractionState; per-feature impls: auto_follow, -│ # commit-log fetch/pagination/apply, diff & file-view loaders, focus, -│ # navigation, log_nav, scroll, session_io, snapshot_io, -│ # terminal_ctrl, tree, tree_nav +├── app.rs, app/ # App coordinates terminal/focus/fullscreen/notice/interaction with +│ # GitViewManager; RepositoryView owns status/log/tree/diff, +│ # auto-follow, tree watch state, and pending selection; +│ # GitViewManager owns repo identity/cache, snapshot/load workers, +│ # commit-log controller, tracking, branch, and ref decorations ├── config.rs, config/ # config.toml root + layout/theme/input, log, panels, │ # plugin ([[plugin]]), web (WebViewerConfig, password bootstrap) ├── workspace/ @@ -153,7 +154,8 @@ src/ │ └── registry.rs, registry/ # ~/.nightcrow/plugins: config snippets, executable │ # resolution, atomic install/list/remove storage ├── git/ -│ ├── diff.rs, diff/ # types, snapshot loader, diff/commit loaders, commit_log, refs +│ ├── diff.rs, diff/ # types, snapshot loader, diff/commit loaders, conflated load worker, +│ │ # commit_log, refs │ ├── clone.rs, clone/ # delegate `git clone` to the binary; URL scheme whitelist │ ├── path/ # repo-relative path validation before any filesystem read │ └── tree/ # lazy read-only directory listing (gitignore filter, symlink guard) @@ -162,7 +164,8 @@ src/ │ # encode_wheel/button/arrow, CSI/SS3 helpers) ├── session/ # daemon-owned, transport-neutral shared session core │ ├── state.rs, operations.rs, reload.rs # ownership, mutations, live config reload -│ ├── catalog/ # opaque repo ids, atomic swap, ordering, config tables +│ ├── catalog/ # pure membership + live runtime reconciliation, +│ │ # opaque repo ids, ordering, config tables │ ├── runtime/ # SnapshotChannel drain + conflated status fan-out │ ├── terminal/ # TerminalHub, PtyBackend ownership, shared terminal frames │ ├── size_owner.rs # which client screen the session PTYs are fitted to @@ -238,12 +241,11 @@ PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다. ## Future Refactor Notes -- `App`은 도메인별 sub-struct(`StatusView`, `LogView`, `DiffPane`, `TerminalState`, - `InteractionState`, `RepoInput`)와 - `app/` 서브모듈로 impl 책임이 나뉘어 있지만, 여전히 한 구조체가 모든 sub-state를 들고 있다. 추가 - 분리가 필요해지면 sub-struct별 명시적 manager로 승격하는 게 다음 단계다. -- 대형 diff에서 j/k 빠른 탐색 시 동기 diff 로드가 여전히 ms 단위 블로킹을 만들 수 있다. Repository - 캐싱으로 `discover` 비용은 제거됐으나, 추가 향상이 필요하면 채널 기반 비동기 로드 + debouncing. +- 저장소별 상태는 `GitViewManager`와 그 안의 `RepositoryView`로 분리됐다. `App`은 terminal/focus/ + fullscreen/notice/interaction을 소유하고 명시적 façade로 UI·입력 계층에 저장소 상태를 제공한다. + 이후 분리는 manager 내부 동작이 독립 수명이나 동시성 경계를 실제로 얻을 때만 진행한다. +- diff/file/commit/ref 로드는 lane별 conflation과 generation guard를 갖춘 `GitLoadWorker`로 비동기화돼 + 있다. 추가 최적화는 측정 결과가 필요할 때 watcher event debouncing이나 lane별 비용을 대상으로 한다. ## Detailed design diff --git a/docs/architecture/git-views.md b/docs/architecture/git-views.md index 5c5f229c..3ffa367a 100644 --- a/docs/architecture/git-views.md +++ b/docs/architecture/git-views.md @@ -5,15 +5,30 @@ tree(read-only 파일 트리) — 를 떠받치는 데이터 파이프라인과 같은 `git2::Repository` 캐시와 같은 경로 검증기를 지나며, 우측 pane(diff/file view)은 세 뷰가 공유한다. +`GitViewManager`가 저장소 경로·opaque id, repository cache, snapshot/load workers, commit-log +controller, branch/tracking/ref decoration을 한 수명으로 묶는다. 그 안의 `RepositoryView`는 +status/log/tree/diff pane, auto-follow, tree watcher dirty set, snapshot 기반 pending selection을 +소유한다. `App`은 terminal·focus·fullscreen·notice·interaction을 소유한 채 이 manager의 명시적 +façade만 UI와 입력 계층에 제공한다. 따라서 프로젝트 close는 manager를 drop해 worker를 함께 +정리하고, daemon set adopt는 같은 manager에 opaque id만 붙여 선택·watcher·cache를 보존한다. + ## Git Diff Pipeline - **백그라운드 worker 스레드**: `SnapshotChannel`이 `load_snapshot`을 호출해 변경 파일 + tracking status를 `mpsc` 채널로 푸시한다(읽는 시점 규칙은 [session.md](session.md#상태는-시간이-아니라-변화에-따라-읽는다-runtimesnapshot_watchrs) 참고). -- **UI 스레드 동기 로드**: 파일/커밋 선택이 바뀌면 `load_*_with_repo`를 직접 호출한다. App은 - `git2::Repository`를 lazy-cache하므로 매 호출마다 `Repository::discover`를 다시 실행하지 않는다. - cache는 프로젝트와 수명을 같이 하므로 무효화 시점이 따로 없다 — 저장소가 바뀌는 유일한 방법이 - 탭을 닫고 새로 여는 것이기 때문. +- **선택 로드 worker**: 파일/커밋 선택, file view, commit drill-down, ref decoration은 + `GitLoadWorker`가 읽고 UI tick은 결과만 적용한다. `git2::Repository`는 `!Send`이므로 worker가 + `Repository::discover`와 cache를 모두 소유한다. 요청은 `(repo, oid/path, generation)`으로 식별하고 + diff/file/commit-files/decorations lane마다 아직 시작하지 않은 요청을 하나로 합친다. 실행 중인 이전 + 요청은 취소할 수 없지만 generation이나 repo가 현재 intent와 다르면 결과를 버리므로 연속 선택, + HEAD 변경, 탭 전환이 과거 내용을 되돌리지 않는다. lane 선택은 round-robin이라 diff 요청이 계속 + 들어와도 file/commit-files/decorations가 굶지 않는다. 프로세스 전체 git I/O와 동일 저장소 I/O에는 + 각각 hard bound가 있고, 종료 제한 안에 끝나지 않은 worker handle도 중앙 registry가 bounded하게 + 추적·회수한다. +- **snapshot reload gate**: 선택 파일의 path·status columns·mtime이 전부 이전 snapshot과 같으면 + 다른 파일이 바뀌었더라도 선택 diff를 다시 읽지 않는다. 선택 파일 자체가 바뀐 in-place refresh만 + 기존 scroll을 유지해 요청하고, 새 선택은 scroll/search cursor를 새 대상에 맞춰 reset한다. - **경로 검증**: 워크트리 안의 파일·디렉토리를 여는 경로는 전부 `git::path::resolve_in_workdir`를 거친다(파일 미리보기와 트리 리스팅 양쪽). plain relative 컴포넌트만 허용하고 `..`·절대경로·NUL·`.git`(대소문자 무시)을 거부하며, 워크디렉토리부터 한 컴포넌트씩 내려가 diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 8e8b6b33..272c4c46 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -14,7 +14,7 @@ trait TerminalBackend { fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>; fn destroy_pane(&mut self, id: PaneId); fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; - fn resize(&mut self, id: PaneId, rows: u16, cols: u16); + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result; fn reorder(&mut self, order: &[PaneId]); // 기본 no-op fn claim_size(&mut self); // 기본 no-op fn drain_events(&mut self) -> Vec; @@ -35,7 +35,9 @@ trait TerminalBackend { 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. 2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 "PTY 크기" 참고). `Resized`를 - 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. + 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. 로컬 `PtyBackend`는 성공 시 + `Applied`, 원격 `HubBackend`는 서버 확인이 남았다는 `Pending`을 반환한다. 호출 실패는 + `Result`로 전파되며 적용 성공처럼 에뮬레이터나 세션 상태에 기록하지 않는다. 3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 투영하는 서버 canonical order다. 4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 @@ -76,6 +78,20 @@ trait TerminalBackend { (`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. +### Catalog는 membership과 runtime을 분리한다 (`session/catalog/`) + +저장소 집합을 결정하는 순수 상태(`CatalogMembership`)와 실제 status worker·terminal hub를 +소유하는 상태(`CatalogRuntime`)는 별개다. membership은 base·browser-added·hidden·order의 합집합과 +재사용하지 않는 id만 계산하고, runtime은 그 결과를 reconcile해 같은 path의 `Arc`를 +그대로 보존한다. 따라서 무관한 탭 변경은 기존 runtime과 SSE subscriber를 교체하지 않는다. + +둘 사이 변경은 `Catalog` façade의 transaction 하나로 직렬화한다. config table 교체도 같은 +transaction을 써서 동시에 열린 저장소는 교체 시점의 fan-out에 포함되거나 새 table로 spawn되는 +둘 중 하나이며, 어느 쪽에도 속하지 않는 틈이 없다. reconcile은 먼저 새 runtime snapshot을 +설치하고 retired entry를 값으로 돌려준다. worker `stop`과 join은 membership·runtime·transaction +lock을 모두 놓은 뒤 실행한다 — 닫히는 저장소 하나의 종료 지연이 조회나 다음 catalog mutation을 +막지 않게 하기 위해서다. + ### 데몬이 세션을 감시한다 (`daemon/watch.rs`) 세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는 @@ -131,12 +147,43 @@ alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 클라이언트가 볼 수 없는 이유로도 일어난다(마지막 커넥션이 끊김, worker tick에서 유예 만료). 기록이 없으면 나중에 읽을 것이 증상뿐이다. - 비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 - 자식이 감는 곳에서 감아야 하기 때문이다. 소유자도 그것을 읽되("clamp됐다"를 그렇게 안다) - "내가 요청한 값" 기록은 유지한다. 그러지 않으면 매 프레임 같은 clamp를 다시 요청한다. + 자식이 감는 곳에서 감아야 하기 때문이다. 소유자는 `desired`(현재 레이아웃), `pending`(마지막 + 전송과 시각), `confirmed`(`Resized`로 확인한 실제 크기)를 분리한다. 늦은 이전 ACK가 에뮬레이터를 + 과거 폭으로 돌려도 `desired != confirmed`가 남아 최종 폭을 다시 요청하며, ACK가 오지 않으면 + 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. +- **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded + queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 + 별도 보관한다. worker는 일반 command를 64개 처리할 때마다 이를 합성 처리해 지속적인 입력에도 + resize가 굶지 않으며, 연결이 끝나면 그 connection의 보류 값을 제거해 재접속 churn에도 저장량을 + 붙은 connection·pane 수 안에 묶는다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 적용을 + 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 갱신하거나 + `Resized`를 브로드캐스트하지 않는다. - 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. +### attach 터미널 수신은 유계 FIFO다 + +attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 저장소의 에뮬레이터는 TUI 렌더 틱에서 +자기 inbox를 소비한다(`daemon/terminal_link.rs`). 소켓 reader가 렌더보다 빠르다는 이유로 메모리를 +무제한 늘리거나, 반대로 최신 출력만 남기는 것은 둘 다 허용되지 않는다. 터미널 바이트는 snapshot이 +아니라 순서 있는 스트림이므로 중간 한 바이트를 버리면 뒤의 모든 escape sequence 해석이 틀어진다. + +- **drain은 저장소별 FIFO prefix만 꺼낸다.** 한 틱에 최대 64개 메시지와 256 KiB를 처리한다. 첫 + 메시지가 256 KiB보다 큰 replay frame이면 그것 하나는 꺼내야 head가 영원히 막히지 않는다. 예산 + 뒤의 메시지는 다음 틱에 남고, 모든 열린 저장소가 틱마다 각자 drain하므로 시끄러운 저장소 하나가 + 다른 탭의 입력·출력과 화면 갱신을 굶기지 않는다. +- **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 + 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 + 건너뛰면 마지막 출력이 사라진다. +- **연결 전체에 256 MiB output + 4,096 message 상한을 둔다.** output allowance는 데몬 쪽 연결 + 큐가 합법적으로 보낼 수 있는 256개의 1 MiB replay frame과 같은 크기이고, 별도 message 상한은 + control event-only 폭주도 저장량을 무제한 키우지 못하게 한다. 저장소를 닫거나 drain하면 그 몫을 + 즉시 돌려준다. + 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 + 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 + screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. + ### 상태는 시간이 아니라 변화에 따라 읽는다 (`runtime/snapshot_watch.rs`) `git status` 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 @@ -267,6 +314,11 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 클라이언트가 옛 크기의 화면을 받지 않게. 반대쪽 기록(alt 밑에 동결된 normal 스냅샷)은 에뮬레이터의 비활성 그리드라 읽을 수 없어 옛 크기로 남는다 — 복귀 후의 출력이 tail로 그 위에 얹히고, 다음 스냅샷 갱신이 마저 고친다. +- **꺼내 둔 resize도 연결 수명을 넘지 못한다.** latest-value map에서 worker가 값을 꺼낸 직후 + 소유자가 떠날 수 있으므로 요청은 원래 connection id를 함께 보존한다. 적용 시 hub state를 먼저 + 잠가 같은 client/connection 등록인지 확인하고, 그 lock 아래 session ownership을 다시 검사한다 + (`state → ownership`, `connect`와 같은 순서). 따라서 disconnect나 소유권 이전이 먼저 완료되면 + 이미 꺼낸 옛 요청도 새 소유자의 PTY에 적용하거나 ACK하지 않는다. - **화면 하나로는 부족하다: `screen` + `since`.** 스냅샷은 worker tick마다 갱신되므로 chunk가 broadcast된 뒤 그것이 스냅샷에 반영되기 전에 클라이언트가 붙을 수 있다. 그래서 스냅샷 이후 broadcast된 바이트를 옆에 함께 들고, replay는 `screen` 다음에 `since`를 보낸다 — 둘을 합치면 @@ -335,7 +387,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 - **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 **딱 한 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 - (`catalog/config_tables.rs`) 그 뒤 `rebuild`가 띄우는 hub가 새 목록을 받는다. + (`catalog/config_tables.rs`) 그 뒤 runtime reconcile이 띄우는 hub가 새 목록을 받는다. - **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`. @@ -376,7 +428,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 범위를 좁히는 것이 `spec_changed`의 진짜 값이다. - **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. -- **reload와 프로젝트 열기의 경합은 Catalog의 mutation lock이 막는다.** 테이블 교체와 "알려줄 +- **reload와 프로젝트 열기의 경합은 Catalog의 façade transaction이 막는다.** 테이블 교체와 "알려줄 저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다 (`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 둘 사이로 빠져 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. @@ -391,7 +443,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 ## Worker Thread Lifecycle (의도된 비대칭) -백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 +완료 후 한 번 답하는 백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, **호출 지점이 hot path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다.** 리뷰 시 이 비대칭을 깨뜨리지 말 것. @@ -408,4 +460,18 @@ path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다 `try_timed_join`은 `src/platform/threading.rs`에 공유 helper로 두고 snapshot/commit-log/PTY 세 곳에서 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 고른다. +`GitLoadWorker`는 예외적으로 프로젝트 수명 동안 살아 있는 conflated worker다. 아직 시작하지 않은 +요청을 lane별 한 슬롯에 덮어써 10만 번의 연속 선택도 큐나 스레드를 10만 개 만들지 않는다. 따라서 +reply receiver drop만으로는 요청을 기다리는 `Condvar`를 깨울 수 없어 `Drop`이 stop flag를 세우고 +깨운 뒤 5ms 동안 완료를 기다린다. 실행 중인 libgit2 호출은 강제 중단하지 않으며 제한 안에 끝나지 +않은 handle은 detach한다. 대신 thread 수명 전체에 process-wide permit을 먼저 발급해 열린 프로젝트 +10개와 멈춘 libgit2 호출 8개를 합한 18개를 실제 thread/FD hard bound로 둔다. 별도의 공정한 FIFO +admission이 process/동일-repo libgit2 동시 호출을 8/1로 제한한다. 취소된 ticket은 큐에서 빠지고, +같은 repo 때문에 막힌 ticket은 unrelated repo의 eligible ticket을 가로막지 않는다. 늦은 reply는 +`(repo, generation)` guard가 버린다. 스레드 생성 실패는 pending request를 유지한 채 다음 submit 또는 +reply poll에서 재시도하되 16 ms부터 두 배씩 늘려 최대 1초까지 기다린다. 첫 실패는 경고하고 같은 실패가 +이어지면 경고도 30초에 한 번으로 제한하며, 생성에 성공하면 지연과 경고 제한을 모두 초기화한다. 예상하지 +못한 worker 종료는 완료된 handle을 회수한 뒤 같은 방식으로 재시작한다. 개별 git load panic은 cache를 +버리고 해당 request에 일반화된 error reply를 보내므로 worker와 이후 request는 계속 진행한다. + ← [Architecture index](../architecture.md) diff --git a/docs/architecture/terminal.md b/docs/architecture/terminal.md index 7706bc68..9dd9b834 100644 --- a/docs/architecture/terminal.md +++ b/docs/architecture/terminal.md @@ -56,7 +56,9 @@ - **Sizing invariant**: `ui::terminal_tab::visible_pane_cells`가 pane Rect의 단일 출처다. `render`가 매 프레임 여기서 그리고, `ui::terminal_content_areas` → `main_loop`의 `resize_visible_panes`도 같은 함수를 읽으므로 pane의 backend PTY + 에뮬레이터 크기가 그려진 셀과 정확히 일치한다. **새 - 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** + 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** 원격 backend에서는 + 요청 직후 에뮬레이터를 낙관적으로 바꾸지 않고 세션의 `Resized` 확인을 따라간다. 원하는 크기와 + 확인된 크기가 다르면 재요청하므로 빠른 연속 resize의 마지막 셀 크기로 수렴한다. - **Input/scroll scope는 그대로**: 키보드 입력, paste, prompt 로깅, 터미널 스크롤 (`TerminalState::active_pane_rows`가 페이지 크기)은 여러 pane이 그려져도 active pane만 겨냥한다. - **Accent는 "active pane"이 아니라 진짜 포커스를 뜻한다**: accent 색은 앱 전역에서 "이 영역이 diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index 557c05ae..c7bd8423 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -139,10 +139,11 @@ legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint ### Polling · 세션 · 자원 -- **Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker와 PTY reader는 - unbounded 채널에 계속 쓰므로), 스냅샷을 *적용*하는 것은 활성 프로젝트뿐이다. 적용은 전체 - `refresh_diff`를 돌리므로 열린 저장소마다 프레임당 git diff를 UI 스레드에서 수행하게 된다. 배경 - 스냅샷은 `pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. +- **Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker, git-load worker와 + PTY reader가 계속 생산하므로), 스냅샷을 *적용*하는 것은 활성 프로젝트뿐이다. 배경 스냅샷은 + `pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. git-load 결과는 git I/O 없이 + generation을 확인하고 메모리 상태만 교체하므로 숨은 프로젝트도 즉시 비운다. 그래야 탭을 떠난 + 사이 끝난 이전 선택 결과가 큐에 남아 복귀 frame을 되돌리지 않는다. - **중복 방지** — 다른 탭이 이미 연 저장소는 두 번 열지 않고 그 탭으로 포커스를 옮긴다. 같은 workdir에 프로젝트 두 개는 스냅샷 worker가 중복으로 돌고 같은 session 파일에 쓴다. git 저장소가 아닌 경로는 canonicalize해서 철자 차이(`/w` vs `/w/`)가 이 검사를 빠져나가지 못하게 한다. @@ -160,19 +161,50 @@ legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint | | 1 프로젝트 | 10 프로젝트 | |---|---|---| - | 스레드 | 6 | 60 | + | 스레드 | 7 | 70 | | RSS | 38MB | 43MB | | 자식 프로세스 | 1 | 19 | | 유휴 CPU | — | 20초에 0.47초 (~2.4%) | 메모리는 프로젝트당 0.5MB 남짓만 늘어 사실상 문제가 아니고, 유휴 CPU도 낮다. 탭 전환은 인덱스 - 변경이라 실측 70ms 수준(대부분 렌더링). 주목할 것은 **스레드가 프로젝트당 6개로 선형 증가**한다는 - 점이다(snapshot worker, commit-log fetch, PTY당 reader/wait 쌍). 60개 자체는 문제가 아니지만 이를 + 변경이라 실측 70ms 수준(대부분 렌더링). 주목할 것은 **스레드가 프로젝트당 7개로 선형 증가**한다는 + 점이다(snapshot worker, git-load worker, commit-log fetch, PTY당 reader/wait 쌍). 70개 자체는 문제가 아니지만 이를 막고 있는 것은 `MAX_PROJECTS`(10)와 pane 상한(8)이다. 상한을 올리자는 논의가 나오면 이 선형성을 - 근거로 재검토해야 한다. 위 측정은 pane 2개 기준이라 최악(10 × 8)은 재보지 않았다. + 근거로 재검토해야 한다. 표의 스레드 수는 git-load worker 추가분을 구조적으로 반영한 값이고, + 나머지 측정치는 pane 2개 기준이라 최악(10 × 8)은 재보지 않았다. - **로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. 첫 `--repo`를, 그것도 없으면 작업 디렉토리를 고정 기준으로 삼는다. +## Polling and dirty frames + +The attached TUI still polls its input and runtime queues every 16 ms so PTY +output, snapshot results, tree watcher events, and log pages are noticed +promptly. Polling is not a frame clock: `application::redraw::RedrawState` +requests a draw only for a state-changing event, a terminal-size change, or a +visible attention/search-caret phase change. The first frame is always drawn; +an unchanged idle tick does no `Terminal::draw` call. Terminal polling reports +output, title, resize, pane lifecycle, recovery, delayed synchronized-update, +and settled-title activity so a redraw cannot depend only on keyboard input. +The status list also schedules the exact Fresh→Warm (5 seconds) and Warm→Cool +(`hot_window_secs`) boundaries for the active repository, so its fade remains +correct without a 60 FPS frame clock. + +` r` remains an explicit full repaint: it clears ratatui's front buffer +and marks the next loop dirty, covering terminal programs that left cells the +diff renderer cannot know about. Input events also request a frame before their +effects are observed, which keeps prefix/overlay/focus changes visible even +when a PTY echo is delayed. Resize events and direct size observations share +the same dirty path, so a missed crossterm resize notification cannot leave the +new geometry unpainted. + +The release measurement is intentionally opt-in because it starts a real shell: +`cargo test --release measure_dirty_redraw -- --ignored --nocapture`. It prints +before/after draw counts and `ratatui::Terminal` CPU timings for +idle, one-second-heartbeat, and every-tick event streams, followed by p95 echo +latency from a real `PtyBackend`, plus the event-to-next-frame p95 bound. The +draw-count and poll-bound assertions are deterministic; timings and PTY latency +are machine-specific evidence, not CI thresholds. + ## Notice Row 힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 diff --git a/docs/architecture/web.md b/docs/architecture/web.md index 6a9fe4de..1ba50a29 100644 --- a/docs/architecture/web.md +++ b/docs/architecture/web.md @@ -303,6 +303,15 @@ Vitest 쪽 권장이며, 결정적으로 `window.matchMedia`를 구현한다(jsd 방식으로 옮기면 된다. `@testing-library/react` 16은 `@testing-library/dom`을 peer로 요구해 함께 설치했다; `user-event`·`jest-dom`은 훅 테스트에 불필요해 컴포넌트 테스트를 시작할 때로 미뤘다. +**큰 diff와 raw file은 viewport만 DOM에 둔다**(`lib/virtualWindow.ts`, +`components/Virtual*`). 200행 이하는 브라우저의 native selection·find·접근성 트리를 그대로 얻도록 +기존 전체 DOM 경로를 쓰고, 그보다 크면 20px 고정 행과 앞뒤 12행 overscan으로 windowing한다. 전체 +높이는 spacer가 보존하므로 scrollbar와 저장한 `scrollTop`은 원본 행 수를 계속 나타낸다. 파일 anchor는 +같은 행 높이로 직접 계산하고, diff의 모든 렌더 행은 자기 `data-hunk`를 가져 header가 viewport 밖이어도 +whole-file 전환이 현재 hunk를 찾는다. Split은 넓은 화면에서 old/new 한 쌍을 같은 virtual row로 +렌더해 세로 정렬을 보존하고, 좁은 화면에서는 hunk별 old 전체 뒤에 new 전체가 오도록 별도 row model을 +쓴다. 20k fixture가 DOM 행 수를 계약으로 고정하고 initial/scroll/split 측정치를 함께 기록한다. + **렌더 실패가 페이지를 가져가지 않게 한다**(`components/feedback/ErrorBoundary.tsx`, `lib/chunkError.ts`). boundary가 하나도 없으면 React는 어떤 렌더 에러에도 트리 전체를 unmount하고, 보는 사람 입장에서 그것은 **서버가 죽은 것과 구분되지 않는다.** 실제로 사라진 청크가 그 모양으로 diff --git a/plugins/nightcrow-recovery/src/helper.rs b/plugins/nightcrow-recovery/src/helper.rs index 9c2c94ab..60cf16c6 100644 --- a/plugins/nightcrow-recovery/src/helper.rs +++ b/plugins/nightcrow-recovery/src/helper.rs @@ -1,21 +1,16 @@ //! The two modes a provider CLI invokes, not a human. //! //! Both run inside a child of the provider's own process, on that process's -//! critical path: Claude Code runs the statusline command on a refresh interval -//! and the hook command as a turn ends. So both do the least possible work — -//! read one JSON object, forward a handful of whitelisted fields, exit — and -//! neither ever reports a failure to its caller. A recovery plugin that is not -//! running must look exactly like one that was never installed. +//! critical path. So both do the least possible work — read one JSON object, +//! forward a handful of whitelisted fields, exit — and neither ever reports a +//! failure to its caller: a recovery plugin that is not running must look +//! exactly like one that was never installed. //! -//! Whitelisting is the privacy boundary. A `StopFailure` payload names a -//! transcript file and carries a provider's own error prose; a statusline payload -//! carries whatever else the provider decided to include. Only the fields the -//! state machine actually reads cross the socket, so nothing else can be -//! accidentally logged, buffered, or written down later. -//! -//! The one thing that leaves this process whole is the statusline payload handed -//! to the command we displaced (see [`status_line`]) — and that command was being -//! given the same bytes by Claude Code before this plugin was installed. We +//! Whitelisting is the privacy boundary: only the fields the state machine +//! actually reads cross the socket, so nothing else can be logged, buffered, +//! or written down later. The one thing that leaves this process whole is the +//! statusline payload handed to the command we displaced (see +//! [`status_line`]) — the same bytes Claude Code was already giving it. We //! narrow what we keep; we do not narrow what someone else was already told. use crate::ipc::{IpcMessage, send, socket_path}; @@ -66,10 +61,10 @@ pub fn hook() -> ExitCode { /// Report that a turn ended, so the host can raise the pane's attention marker. /// -/// Sends no payload: `Stop` fires whatever the outcome, and which outcome it was -/// is not something the marker distinguishes. Reading stdin is still necessary — -/// Claude Code writes the hook payload there and a helper that never drained it -/// would leave the provider writing into a full pipe. +/// Sends no payload: `Stop` fires whatever the outcome, and which outcome it +/// was is not something the marker distinguishes. Reading stdin is still +/// necessary — a helper that never drained it would leave the provider +/// writing into a full pipe. pub fn turn_end() -> ExitCode { let _ = read_stdin_bytes(); if let Some(token) = pane_token() { @@ -87,8 +82,8 @@ pub fn turn_end() -> ExitCode { /// Forward the statusline's `rate_limits` and print a line — the line the user's /// own statusline command printed, whenever installing this plugin displaced one. -/// Claude Code's `statusLine` holds a single command, so the only way not to cost -/// the user their statusline is to run it from ours; see [`status_line`]. +/// `statusLine` holds a single command, so the only way not to cost the user +/// their statusline is to run it from ours; see [`status_line`]. pub fn statusline() -> ExitCode { let raw = read_stdin_bytes(); let displaced = status_line::displaced(); @@ -115,9 +110,10 @@ struct Refresh { } /// Decide both halves of a refresh without touching the socket or stdout, so -/// every way a displaced command can disappoint us stays testable — and so the -/// usage numbers are read out of the payload before anything is delegated, which -/// is what keeps a misbehaving statusline command from costing us them. +/// every way a displaced command can disappoint us stays testable — and so +/// the usage numbers are read out of the payload before anything is +/// delegated, which is what keeps a misbehaving statusline command from +/// costing us them. fn refresh(raw: &[u8], displaced: Option<&Value>, budget: Duration) -> Refresh { let rate_limits = parse_object(raw).and_then(rate_limits_of); let line = status_line::line(displaced, raw, rate_limits.as_ref(), budget); @@ -139,11 +135,10 @@ fn pane_token() -> Option { } /// Every byte the provider wrote, kept exactly as it wrote them. The statusline -/// helper hands these on to the command it displaced, and a re-serialised copy is -/// not the same thing: key order and number formatting are the provider's to -/// choose, and a command that was reading its input before we existed should not -/// find it rearranged now. A read that fails part-way keeps what did arrive — -/// unparseable, and treated as such below. +/// helper hands these on to the command it displaced, and a re-serialised copy +/// is not the same thing: key order and number formatting are the provider's to +/// choose. A read that fails part-way keeps what did arrive — unparseable, +/// and treated as such below. fn read_stdin_bytes() -> Vec { let mut raw = Vec::new(); let _ = std::io::stdin() diff --git a/plugins/nightcrow-recovery/src/helper_delegate.rs b/plugins/nightcrow-recovery/src/helper_delegate.rs index 622732ec..3af09cff 100644 --- a/plugins/nightcrow-recovery/src/helper_delegate.rs +++ b/plugins/nightcrow-recovery/src/helper_delegate.rs @@ -1,10 +1,9 @@ //! Running a statusline command that is not ours, on a budget. //! //! Split out of `helper_statusline.rs` so that file decides *which* line gets -//! printed and this one is the process plumbing under it. Everything here is -//! written for a caller that must not be made to wait and must not be made to -//! fail: the child is bounded, killed when it overruns, reaped on every path, and -//! any disappointment comes back as `None`. +//! printed and this one is the process plumbing under it. Written for a caller +//! that must not be made to wait or to fail: the child is bounded, killed when +//! it overruns, reaped on every path, and any disappointment comes back as `None`. use std::io::{Read, Write}; use std::process::{Child, Command, Stdio}; @@ -14,14 +13,10 @@ use std::time::{Duration, Instant}; /// A POSIX shell, resolved on `PATH`. Not `$SHELL`: an interactive shell would /// read the user's rc files on every single refresh. /// -/// Windows included, and deliberately so. The command being run here is one -/// Claude Code was running before this plugin displaced it, and Claude Code runs -/// a `statusLine` through a POSIX shell on every platform — its own documented -/// examples are `$(...)`, `jq` pipelines and `~` paths, and the ones people -/// actually have installed reach for `stty`, `awk` and MSYS-style `/c/...` -/// paths. Handing such a line to `cmd.exe` does not run it differently; it fails -/// to run it at all, and the user silently loses their statusline. Which shell -/// the *host's panes* use is a separate setting and not this decision. +/// Windows included, and deliberately so: Claude Code runs a `statusLine` +/// through a POSIX shell on every platform, so the line being run here is one +/// `cmd.exe` would not run at all — the user would silently lose their +/// statusline. Which shell the *host's panes* use is a separate setting. const SHELL: &str = "sh"; const SHELL_COMMAND_ARG: &str = "-c"; @@ -45,10 +40,9 @@ const EXIT_POLL: Duration = Duration::from_millis(2); /// /// Through the platform shell's command mode (`sh -c` or `cmd.exe /C`), not an /// argv we split ourselves: the provider documents that a `statusLine` command -/// "runs in a shell", and its own examples rely on it — a `~` path, a `jq` -/// pipeline, an inline `$(...)`. Re-splitting the string the user wrote would -/// quietly change what it means. This is the user's own configuration rather -/// than input from a stranger, but it is also not ours to reinterpret. +/// "runs in a shell", and its examples (`~` paths, `jq` pipelines, inline +/// `$(...)`) rely on it. Re-splitting the string would quietly change what it +/// means — it is the user's own configuration, not ours to reinterpret. pub(super) fn capture(command: &str, raw: &[u8], budget: Duration) -> Option { let deadline = Instant::now() + budget; let mut child = spawn_shell(command)?; @@ -126,10 +120,8 @@ fn shell_child(shell: &str, arg: &str, command: &str) -> std::io::Result /// Whether the child finished, and finished happily, before `deadline`. /// -/// Polled rather than waited on: `wait` has no timeout, and a command that closes -/// its stdout and then sleeps must not get to hold a refresh open. Stdout is -/// already at EOF by the time this is called, so the first look nearly always -/// finds the child gone. +/// Polled rather than waited on: `wait` has no timeout, and a command that +/// closes its stdout and then sleeps must not get to hold a refresh open. fn exited_well(child: &mut Child, deadline: Instant) -> bool { loop { match child.try_wait() { diff --git a/plugins/nightcrow-recovery/src/helper_statusline.rs b/plugins/nightcrow-recovery/src/helper_statusline.rs index a432e015..dc16b7a8 100644 --- a/plugins/nightcrow-recovery/src/helper_statusline.rs +++ b/plugins/nightcrow-recovery/src/helper_statusline.rs @@ -1,17 +1,17 @@ //! The one line Claude Code renders, and who gets to write it. //! //! Installing this plugin necessarily takes the user's statusline away: -//! `statusLine` in `settings.json` holds one command, not a list, so ours replaces -//! whatever was there. Chaining is the only way to give it back — install recorded -//! the value it displaced in a sidecar, and every refresh runs that command with -//! the very bytes Claude Code sent us and prints what it printed. This plugin's own -//! two-number line ([`render_statusline`]) stands in only when there is nothing to -//! chain to. Running the command itself is [`delegate`]'s job. +//! `statusLine` in `settings.json` holds one command, not a list, so ours +//! replaces whatever was there. Chaining is the only way to give it back — +//! install recorded the value it displaced in a sidecar, and every refresh +//! runs that command with the very bytes Claude Code sent us and prints what +//! it printed. This plugin's own two-number line ([`render_statusline`]) +//! stands in only when there is nothing to chain to. Running the command +//! itself is [`delegate`]'s job. //! -//! Nothing here fails upwards. A statusline that shows an error is worse than a -//! plain one, so a missing sidecar, a value we cannot execute, a spawn failure, a -//! non-zero exit, a wedged child and non-UTF-8 output all end in the same place: -//! our own line, printed as if no chaining had been attempted. +//! Nothing here fails upwards. A statusline that shows an error is worse than +//! a plain one, so every disappointment ends in the same place: our own line, +//! printed as if no chaining had been attempted. use crate::hooks::{SettingsPaths, displaced_statusline, is_ours}; use serde_json::{Map, Value}; @@ -27,15 +27,13 @@ const STATUSLINE_FALLBACK: &str = "nightcrow: watching"; /// How long a displaced statusline command may take before we give up on it. /// -/// Claude Code documents no timeout for a statusline: it debounces updates at 300ms -/// and cancels an in-flight script when the next update arrives, so the provider is -/// already the one deciding we took too long. This bound is for the other direction -/// — a command that never returns must not make this process immortal, and our own -/// line has to get printed either way. Two seconds is many times that debounce and -/// generous even for the `git`-shelling scripts the provider's own guidance calls -/// slow, while keeping a wedged child's cost finite. It is also inside the five -/// seconds this plugin asks Claude Code to allow its hook, the most patience -/// anything here claims of the provider. +/// Claude Code documents no timeout for a statusline and cancels an in-flight +/// script when the next update arrives, so the provider is already the one +/// deciding we took too long. This bound is for the other direction: a command +/// that never returns must not make this process immortal. Two seconds is +/// generous even for the `git`-shelling scripts the provider's own guidance +/// calls slow, and inside the five seconds this plugin asks Claude Code to +/// allow its hook — the most patience anything here claims of the provider. pub(super) const BUDGET: Duration = Duration::from_secs(2); const TYPE_KEY: &str = "type"; @@ -77,14 +75,11 @@ fn delegated(displaced: Option<&Value>, raw: &[u8], budget: Duration) -> Option< /// /// Install recorded that value verbatim, so this reads the shape the provider /// documents and the one this plugin itself writes — an object with `type` and -/// `command` — and also accepts a bare string, which costs nothing and is the -/// obvious hand-written form. A value with some other `type` is a statusline we do -/// not know how to run, and guessing at it is worse than standing in for it; so is -/// `null`, which is what install records when it displaced nothing at all. +/// `command` — and also accepts a bare string, which is the obvious +/// hand-written form. A value with some other `type` is a statusline we do not +/// know how to run, and guessing at it is worse than standing in for it. /// -/// The entry's other fields are Claude Code's to act on, not ours: `padding` and -/// `refreshInterval` describe how the provider treats a statusline, and the -/// provider is reading them off our entry now, not off this one. +/// The entry's other fields are Claude Code's to act on, not ours. fn command_of(value: &Value) -> Option<&str> { let command = match value { Value::String(command) => command.as_str(), diff --git a/plugins/nightcrow-recovery/src/hooks_merge.rs b/plugins/nightcrow-recovery/src/hooks_merge.rs index 10e41a38..0d78e3b2 100644 --- a/plugins/nightcrow-recovery/src/hooks_merge.rs +++ b/plugins/nightcrow-recovery/src/hooks_merge.rs @@ -45,16 +45,12 @@ const STATUSLINE_PADDING: u64 = 2; /// Quote a path for the POSIX shell these commands are run in. /// -/// Claude Code runs a hook and a `statusLine` through a shell, on every platform -/// — its own documented examples are shell one-liners, and the entries other -/// tools install here are `if [ -f '...' ]; then ...`. So a Windows path cannot -/// be written bare: the shell reads each backslash as an escape, so -/// `C:\Users\me\plugin` arrives as `C:Usersmeplugin` and is simply not found. -/// Single quotes suspend every interpretation the shell would otherwise make, -/// which covers spaces in the path as well. -/// -/// A single quote cannot appear inside single quotes, so an embedded one is -/// closed, escaped on its own, and reopened. +/// Claude Code runs a hook and a `statusLine` through a shell on every +/// platform, and that shell reads each backslash of a Windows path as an +/// escape — `C:\Users\me\plugin` arrives as `C:Usersmeplugin` and is simply +/// not found. Single quotes suspend every interpretation the shell would +/// otherwise make, spaces included; an embedded one is closed, escaped on its +/// own, and reopened. fn shell_quoted(path: &str) -> String { format!("'{}'", path.replace('\'', r"'\''")) } @@ -162,7 +158,6 @@ pub(crate) fn merge_into(settings: &mut Value, exe: &str) -> Result<(Vec Ok((changes, displaced)) } -/// Remove exactly what [`merge_into`] added, putting `restore` back as /// Put one command into one hook event's matcher group, creating whatever is /// missing and touching nothing else. fn merge_hook( @@ -201,6 +196,7 @@ fn merge_hook( Ok(()) } +/// Remove exactly what [`merge_into`] added, putting `restore` back as /// `statusLine` when it holds a value we recorded. Containers we empty are /// collapsed so the file returns to its original shape. pub(crate) fn strip_from(settings: &mut Value, restore: Option) -> Result> { diff --git a/plugins/nightcrow-recovery/src/ipc.rs b/plugins/nightcrow-recovery/src/ipc.rs index e5a82702..69734004 100644 --- a/plugins/nightcrow-recovery/src/ipc.rs +++ b/plugins/nightcrow-recovery/src/ipc.rs @@ -1,17 +1,16 @@ //! The private socket a provider's helper processes report through. //! -//! Claude Code invokes a hook command and a statusline command as children of -//! the `claude` process, which means they inherit its environment — including the -//! [`PANE_TOKEN_ENV`] value nightcrow injected when it spawned the pane. Those -//! children live for milliseconds and must not block their parent, so they do the -//! smallest possible thing: connect, write one line, exit. This module is that -//! line's format and both ends of the socket. +//! Claude Code invokes a hook and a statusline command as children of the +//! `claude` process, so they inherit the [`PANE_TOKEN_ENV`] value nightcrow +//! injected. Those children live for milliseconds and must not block their +//! parent: connect, write one line, exit. This module is that line's format +//! and both ends of the socket. //! -//! Trust posture: anything that can reach the socket can claim to be any pane, so -//! the socket is created 0600 inside a 0700 directory and every field of every -//! message is validated before it reaches the state machine. The token is a -//! correlation key, never an authorisation: the worst a forged message can do is -//! make this plugin ask the host for something, and the host judges that again. +//! Trust posture: anything that can reach the socket can claim to be any pane, +//! so the socket is created 0600 inside a 0700 directory and every field is +//! validated before it reaches the state machine. The token is a correlation +//! key, never an authorisation — the worst a forged message can do is make +//! this plugin ask the host for something, and the host judges that again. use crate::protocol::PaneToken; use crate::provider::{OutOfBand, SignalKind}; @@ -63,11 +62,10 @@ pub const MAX_IPC_LINE_BYTES: usize = 8 * 1024; /// that so a future widening does not need a change here. const MAX_TOKEN_LEN: usize = 64; -/// How long either end will block on the socket. -/// -/// The sender runs inside a provider's hook child, so it must give up quickly -/// rather than hold up someone's CLI; the receiver uses the same bound so one -/// stalled client cannot park the accept loop. +/// How long either end will block on the socket. The sender runs inside a +/// provider's hook child, so it must give up quickly rather than hold up +/// someone's CLI; the receiver uses the same bound so one stalled client +/// cannot park the accept loop. const IPC_TIMEOUT: Duration = Duration::from_millis(500); /// One report from a provider helper process. @@ -96,15 +94,12 @@ pub const RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// Where the socket lives. /// -/// The host's directory when it named one, because a plugin process belongs to -/// one hub and a hub is per repository: a session with several projects runs -/// several of this binary, and one fixed path would let only the first bind. -/// The rest would find the address taken and run without a socket, and a -/// helper inside a pane would reach whichever instance won rather than the one -/// watching it. -/// -/// Falling back to the old fixed location keeps this runnable by hand and under -/// a host too old to say — one instance, one socket, as before. +/// The host's directory when it named one: a hub is per repository, so a +/// session with several projects runs several of this binary and one fixed +/// path would let only the first bind — a helper inside a pane would then +/// reach whichever instance won rather than the one watching it. Falling back +/// to the old fixed location keeps this runnable by hand and under a host too +/// old to say. pub fn socket_path() -> Result { if let Some(dir) = std::env::var_os(RUNTIME_DIR_ENV).filter(|d| !d.is_empty()) { return Ok(PathBuf::from(dir).join(SOCKET_FILE)); diff --git a/plugins/nightcrow-recovery/src/protocol.rs b/plugins/nightcrow-recovery/src/protocol.rs index 7ed208ca..adffd075 100644 --- a/plugins/nightcrow-recovery/src/protocol.rs +++ b/plugins/nightcrow-recovery/src/protocol.rs @@ -1,10 +1,10 @@ //! The plugin's side of nightcrow's NDJSON plugin contract. //! //! Deliberately a standalone copy of the host's `src/plugin/protocol.rs` rather -//! than a shared crate: a plugin is built and shipped separately from the host, -//! so it is written against a *version* of the contract. [`PROTOCOL_VERSION`] -//! is what makes a mismatch loud instead of half-understood, and a copy is what -//! makes the version claim honest. +//! than a shared crate: a plugin is built and shipped separately from the +//! host, so it is written against a *version* of the contract. +//! [`PROTOCOL_VERSION`] is what makes a mismatch loud instead of +//! half-understood, and a copy is what makes the version claim honest. use serde::{Deserialize, Serialize}; @@ -30,8 +30,8 @@ pub type PaneToken = String; pub type PaneGeneration = u32; /// Env var carrying the pane token into the pane's child processes, and hence -/// into a provider CLI's hook and statusline helpers. That inheritance is how an -/// out-of-band signal is attributed to a pane; cwd cannot do it, because +/// into a provider CLI's hook and statusline helpers. That inheritance is how +/// an out-of-band signal is attributed to a pane; cwd cannot do it, because /// nightcrow allows several panes on one repository. pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; diff --git a/plugins/nightcrow-recovery/src/provider/codex.rs b/plugins/nightcrow-recovery/src/provider/codex.rs index c577df0c..9a963833 100644 --- a/plugins/nightcrow-recovery/src/provider/codex.rs +++ b/plugins/nightcrow-recovery/src/provider/codex.rs @@ -3,22 +3,21 @@ //! Codex has no hook, no statusline and no `status` subcommand, and it *exits* //! when the usage limit is hit — with exit code 1, indistinguishable from any //! other failure — so neither the exit code nor a still-running process can be -//! used as a signal. What codex does have is a per-session rollout file, and that -//! is the primary source here: [`Provider::poll`] tails the pane's rollout and +//! used as a signal. What codex has is a per-session rollout file, and that is +//! the primary source here: [`Provider::poll`] tails the pane's rollout and //! acts on the `turn_complete` record whose `error.codex_error_info` is -//! `usage_limit_exceeded`, taking the deadline from the most recent `token_count` -//! record. `EventMsg::Error` is not persisted to the rollout, so it is not looked -//! for. Terminal text is a documented fallback only, and a reset time is never -//! parsed out of it. +//! `usage_limit_exceeded`, taking the deadline from the most recent +//! `token_count` record. Terminal text is a documented fallback only, and a +//! reset time is never parsed out of it. //! //! Recovery is always a relaunch (`codex resume `), never typed //! input. `codex resume --last` is deliberately never used: nightcrow allows //! several codex panes on one repository, so "the last session" could belong to -//! another pane. Without an unambiguous session id this adapter holds. +//! another pane. //! //! Layout: `codex_pane.rs` holds the per-pane watching state, -//! `codex_sessions.rs` finds the pane's rollout file and `codex_rollout.rs` holds -//! the pure record grammar. This file holds only the `Provider` contract. +//! `codex_sessions.rs` finds the pane's rollout file and `codex_rollout.rs` +//! holds the pure record grammar. This file holds only the `Provider` contract. use super::{LimitEvent, PaneContext, Provider, ResumePlan}; use crate::protocol::PaneToken; diff --git a/plugins/nightcrow-recovery/src/provider/codex_pane.rs b/plugins/nightcrow-recovery/src/provider/codex_pane.rs index a370d3c8..87d087b2 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_pane.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_pane.rs @@ -1,6 +1,6 @@ -//! Per-pane, per-generation state for the codex adapter: which rollout file this -//! pane's session is writing, how far into it we have read, and the tail of -//! terminal output kept for the fallback needle match. +//! Per-pane, per-generation state for the codex adapter: which rollout file +//! this pane's session is writing, how far into it we have read, and the tail +//! of terminal output kept for the fallback needle match. //! //! Split out of `codex.rs` to keep both files inside the project's 300-line //! limit; `codex.rs` keeps the `Provider` contract and this file keeps the @@ -55,9 +55,10 @@ pub(super) struct PaneState { pending: Vec, session_id: Option, resets_at: Option, - /// Which window codex reported as reached. Parsed because the record is seen - /// only once, but kept out of `detail`, which carries `codex_error_info` - /// alone so no other provider-side string can widen what this plugin says. + /// Which window codex reported as reached. Parsed because the record is + /// seen only once, but kept out of `detail`, which carries + /// `codex_error_info` alone so no other provider-side string can widen + /// what this plugin says. reached_type: Option, output_tail: String, output_latched: bool, diff --git a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs index b6940a42..9a83cdf4 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs @@ -4,10 +4,10 @@ //! filesystem, and so every file stays inside the project's 300-line limit. //! //! Every rollout line has the shape -//! `{"timestamp":..,"ordinal":N,"type":"","payload":{..}}`. Only three tags -//! matter to recovery; everything else — including tags added by a future codex -//! release — is ignored silently, because an adapter that fails on unknown -//! records would break on every upgrade. +//! `{"timestamp":..,"ordinal":N,"type":"","payload":{..}}`. Only three +//! tags matter to recovery; everything else — including tags added by a +//! future codex release — is ignored silently, because an adapter that fails +//! on unknown records would break on every upgrade. use crate::provider::reset_epoch_from_json; use serde_json::Value; diff --git a/plugins/nightcrow-recovery/src/provider/codex_sessions.rs b/plugins/nightcrow-recovery/src/provider/codex_sessions.rs index db3dfbe1..8a8e7e23 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_sessions.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_sessions.rs @@ -14,11 +14,12 @@ const MONTH_DAY_DIR_LEN: usize = 2; /// How many day directories are searched for the pane's session. /// -/// The directories are named in *local* time and this crate has no date library, -/// so instead of computing today's name the `sessions/` tree is listed and the -/// lexicographically greatest day directories are taken — zero-padded -/// `YYYY/MM/DD` sorts chronologically. Two of them, because a session started -/// before local midnight keeps writing into yesterday's directory. +/// The directories are named in *local* time and this crate has no date +/// library, so instead of computing today's name the `sessions/` tree is +/// listed and the lexicographically greatest day directories are taken — +/// zero-padded `YYYY/MM/DD` sorts chronologically. Two of them, because a +/// session started before local midnight keeps writing into yesterday's +/// directory. const CANDIDATE_DAY_DIRS: usize = 2; /// Rollout files in the newest day directories that were modified at or after diff --git a/plugins/nightcrow-recovery/src/provider/mod.rs b/plugins/nightcrow-recovery/src/provider/mod.rs index 0d1209e6..67f197b8 100644 --- a/plugins/nightcrow-recovery/src/provider/mod.rs +++ b/plugins/nightcrow-recovery/src/provider/mod.rs @@ -71,9 +71,8 @@ pub enum SignalKind { /// The `rate_limits` object from Claude Code's statusline payload. RateLimits, /// Claude Code's `Stop` hook: a turn ended, however it ended. Carries no - /// payload — the fact that it fired is the whole message — and never - /// reaches a provider, because wanting the person back is not a provider - /// question. + /// payload and never reaches a provider, because wanting the person back + /// is not a provider question. TurnEnd, } @@ -180,16 +179,15 @@ pub fn detect(command: Option<&str>) -> Option> { } /// Pick an adapter from a signal that arrived over the IPC socket, for a pane -/// whose command line says nothing — the shell somebody opened and then started -/// a provider CLI inside by hand. +/// whose command line says nothing — the shell somebody opened and then +/// started a provider CLI inside by hand. /// /// Sound because a [`SignalKind`] is minted by exactly one provider's helper: /// a `stop_failure` line can only have come from the Claude Code hook this -/// binary installed into Claude Code's own settings. The signal is therefore -/// evidence of what the pane is running, in a way terminal text never is — which -/// is why this is a lookup on the wire kind and deliberately not a second -/// sniffing path. A kind added later has to be classified here rather than -/// falling through to a guess. +/// binary installed. The signal is therefore evidence of what the pane is +/// running, in a way terminal text never is — which is why this is a lookup on +/// the wire kind and deliberately not a second sniffing path. A kind added +/// later has to be classified here rather than falling through to a guess. pub fn detect_from_signal(kind: SignalKind) -> Option> { match kind { SignalKind::StopFailure | SignalKind::RateLimits | SignalKind::TurnEnd => { diff --git a/plugins/nightcrow-recovery/src/provider/opencode.rs b/plugins/nightcrow-recovery/src/provider/opencode.rs index 76022285..b15933bf 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode.rs @@ -1,12 +1,11 @@ //! OpenCode adapter — deliberately observe-only. //! -//! OpenCode retries a retryable API error *without bound*: there is no -//! max-attempt constant, the backoff starts at 2 s and doubles, and the 30 s cap -//! applies only when the response carried no `retry-after` header — with one the -//! cap is ~24.8 days. So "wait for the retries to run out" is a state this -//! adapter can never reach, and a pane in `retry` is hands off: no input, no -//! relaunch, no abort. It only reports, and only once the retry is demonstrably -//! over — the session went `idle`, or the process exited. +//! OpenCode retries a retryable API error *without bound*: the 30 s cap applies +//! only when the response carried no `retry-after` header — with one the cap is +//! ~24.8 days. So "wait for the retries to run out" is a state this adapter can +//! never reach, and a pane in `retry` is hands off: no input, no relaunch, no +//! abort. It only reports, and only once the retry is demonstrably over — the +//! session went `idle`, or the process exited. //! //! State comes from the local server's `GET /session/status`, which is //! first-class server state rather than screen scraping. Terminal text is not @@ -27,7 +26,8 @@ pub use http::{ pub const DEFAULT_PORT: u16 = 4096; /// Override for a user who always runs the server elsewhere. A `--port` on the -/// pane's own command line wins over it: that is the truth about *this* process. +/// pane's own command line wins over it: that is the truth about *this* +/// process. const PORT_ENV: &str = "NIGHTCROW_OPENCODE_PORT"; /// Snapshot of every session the server knows about. @@ -198,8 +198,8 @@ impl Provider for OpenCode { _now_epoch: i64, ) -> Option { // Intentionally blind: OpenCode's TUI retry format string is unverified, - // so any needle list here would be a guess, and a wrong guess parks a - // healthy pane. The status endpoint is authoritative; the screen is not. + // and a wrong needle guess parks a healthy pane. The status endpoint is + // authoritative; the screen is not. None } @@ -208,18 +208,17 @@ impl Provider for OpenCode { if self.fired { return None; } - // The process is gone, so the last thing we saw is final and there is - // nothing left on the server worth asking about. if self.exited { + // The process is gone, so the last thing we saw is final. return self.emit(now_epoch); } if !self.due(now_epoch) { return None; } self.last_poll = Some(now_epoch); - // No server, a non-200, or an unreadable body is ordinary — the user need - // not be running the server at all. Swallow it, and let the interval keep - // the next attempt from becoming a tight loop. + // No server, a non-200, or an unreadable body is ordinary — the user + // need not be running the server at all. Swallow it, and let the + // interval keep the next attempt from becoming a tight loop. let statuses = parse_status_body(&self.fetch_status().ok()?); if let Some(status) = statuses .iter() diff --git a/plugins/nightcrow-recovery/src/provider/opencode_http.rs b/plugins/nightcrow-recovery/src/provider/opencode_http.rs index 151c59e7..b2449643 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode_http.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode_http.rs @@ -125,10 +125,10 @@ fn status_kind(status: &Value) -> StatusKind { /// Resolve the ambiguous `next` field to an absolute unix time in **seconds**. /// /// Whether OpenCode reports an absolute epoch (in which unit) or a relative -/// delay is unverified, so all three readings are tried. The order is by safety -/// rather than by likelihood: absolute readings come first, because over-waiting -/// only costs time while firing early walks straight back into the limit. `None` -/// means "no deadline", which degrades to the machine's own bounded backoff. +/// delay is unverified, so all three readings are tried, ordered by safety +/// rather than by likelihood: over-waiting only costs time, while firing early +/// walks straight back into the limit. `None` means "no deadline", which +/// degrades to the machine's own bounded backoff. pub fn interpret_next(next: i64, now_epoch: i64) -> Option { // Zero or negative is "now" or a corrupt value; both would fire immediately, // so neither is accepted as a deadline. @@ -155,8 +155,8 @@ pub fn interpret_next(next: i64, now_epoch: i64) -> Option { /// /// Deliberately no transfer-encoding handling: a chunked answer comes back with /// its framing intact, [`parse_status_body`] then finds no statuses in it, and -/// the poll degrades to "nothing to report" — the same outcome as no server at -/// all. That is the right failure for an adapter that must never guess. +/// the poll degrades to "nothing to report" — the right failure for an adapter +/// that must never guess. pub fn http_get(port: u16, path: &str, timeout: Duration) -> anyhow::Result { anyhow::ensure!( is_safe_path(path), diff --git a/plugins/nightcrow-recovery/src/runloop.rs b/plugins/nightcrow-recovery/src/runloop.rs index 378b1e35..5a714632 100644 --- a/plugins/nightcrow-recovery/src/runloop.rs +++ b/plugins/nightcrow-recovery/src/runloop.rs @@ -249,9 +249,9 @@ fn on_signal( let (token, signal) = msg.into_signal(); return deliver_signal(panes, &token, &signal); } - // Not a pane we were told about — and that is the common case rather than the - // odd one. The token is proof the sender runs inside one of the host's panes, - // so ask for it; a token from another nightcrow session simply goes + // Not a pane we were told about — and that is the common case rather than + // the odd one. The token is proof the sender runs inside one of the host's + // panes, so ask for it; a token from another nightcrow session simply goes // unanswered. See [`Adoptions`] for why asking is bounded. if let Some(command) = adoptions.request(msg, Instant::now()) { emit(&command)?; diff --git a/plugins/nightcrow-recovery/src/runloop_adopt.rs b/plugins/nightcrow-recovery/src/runloop_adopt.rs index bd0752f9..b665c296 100644 --- a/plugins/nightcrow-recovery/src/runloop_adopt.rs +++ b/plugins/nightcrow-recovery/src/runloop_adopt.rs @@ -1,16 +1,16 @@ //! Asking the host for a pane it never named to us. //! -//! The dominant way a coding CLI gets started is by hand: the user opens a plain -//! shell and types `claude` into it. That pane's `[[startup_command]]` names no -//! plugin, so the host never mentions it — but the CLI's hook still reaches us -//! over the socket, carrying the token the host put in that pane's environment. -//! Presenting the token back is the whole request; the host decides whether to -//! honour it, and never tells us when it does not. +//! The dominant way a coding CLI gets started is by hand: the user opens a +//! plain shell and types `claude` into it. That pane's `[[startup_command]]` +//! names no plugin, so the host never mentions it — but the CLI's hook still +//! reaches us over the socket, carrying the token the host put in that pane's +//! environment. Presenting the token back is the whole request; the host +//! decides whether to honour it, and never tells us when it does not. //! -//! Everything here exists because of that silence. A refusal is indistinguishable -//! from a token belonging to another nightcrow session's pane, so a request that -//! goes unanswered must not be repeated in a tight loop and must not leave state -//! behind that grows with every stranger that knocks. +//! Everything here exists because of that silence: a refusal is +//! indistinguishable from a token belonging to another nightcrow session, so +//! an unanswered request must not be repeated in a tight loop and must not +//! leave state behind that grows with every stranger that knocks. use crate::ipc::IpcMessage; use crate::protocol::{PaneToken, PluginCommand, watch_pane}; @@ -32,20 +32,18 @@ const MAX_PENDING: usize = 8; /// The host answers in milliseconds or never, so this is not a retry interval — /// it is the rate at which a token that is not ours may cost us a command. /// Claude Code's statusline runs on every render, so without it a foreign pane -/// would have us writing a request several times a second, all refused and every -/// one of them counted against the host's per-tick command budget alongside the -/// requests that matter. Half a minute is far longer than any honoured request -/// takes and short enough that a pane which only just became ours is not shut -/// out for long. +/// would have us writing a refused request several times a second, each counted +/// against the host's per-tick command budget. Half a minute is far longer than +/// any honoured request takes and short enough that a pane which only just +/// became ours is not shut out for long. const REQUEST_COOLDOWN: Duration = Duration::from_secs(30); /// One outstanding request, and the signal that justified making it. struct Pending { /// Kept so the adapter still gets it. The signal arrives *before* the pane - /// does — it is the reason the pane arrives at all — and the host replays no - /// history to a pane it has just handed over, so dropping it would lose the - /// very limit being recovered from and leave the pane parked until the - /// provider happened to fail again. + /// does — it is the reason the pane arrives at all — and the host replays + /// no history to a pane it has just handed over, so dropping it would lose + /// the very limit being recovered from. signal: OutOfBand, asked_at: Instant, } @@ -91,9 +89,8 @@ impl Adoptions { /// again. /// /// Without it a handful of foreign tokens would hold every slot for the - /// process's whole life, and a pane that later became ours could not get a - /// request in. Giving up is safe: a pane that really is ours signals again, - /// and the held signal is stale by then anyway. + /// process's whole life. Giving up is safe: a pane that really is ours + /// signals again, and the held signal is stale by then anyway. pub(crate) fn prune(&mut self, now: Instant) { self.0 .retain(|_, p| now.saturating_duration_since(p.asked_at) < REQUEST_COOLDOWN); diff --git a/plugins/nightcrow-recovery/src/runloop_io.rs b/plugins/nightcrow-recovery/src/runloop_io.rs index fa647caa..2436d20d 100644 --- a/plugins/nightcrow-recovery/src/runloop_io.rs +++ b/plugins/nightcrow-recovery/src/runloop_io.rs @@ -1,8 +1,8 @@ //! The plugin's two ends of the host's NDJSON stream. //! -//! Split out of `runloop.rs` so that file is the loop's reasoning and this one is -//! its plumbing. Everything the plugin says leaves through [`emit`], called only -//! from the main thread, which is what keeps two half-written lines from +//! Split out of `runloop.rs` so that file is the loop's reasoning and this one +//! is its plumbing. Everything the plugin says leaves through [`emit`], called +//! only from the main thread, which is what keeps two half-written lines from //! interleaving on stdout. use crate::ipc::IpcMessage; diff --git a/plugins/nightcrow-recovery/src/state.rs b/plugins/nightcrow-recovery/src/state.rs index 844461fa..f445fa48 100644 --- a/plugins/nightcrow-recovery/src/state.rs +++ b/plugins/nightcrow-recovery/src/state.rs @@ -7,9 +7,9 @@ //! cases (a stale generation, a clock jump, an exhausted attempt budget) are //! ordinary unit tests rather than something only reproducible by waiting. //! -//! Safety posture: this machine never decides that a pane is alive or idle. It -//! only ever repeats back what the host told it, and it refuses to ask for input -//! unless the host has said both. The host judges every request again anyway. +//! Safety posture: this machine never decides that a pane is alive or idle; it +//! only repeats back what the host told it, and refuses to ask for input unless +//! the host has said both. The host judges every request again anyway. use crate::protocol::{PROTOCOL_VERSION, PaneGeneration, PaneToken, PluginCommand, PluginEvent}; use crate::provider::{LimitEvent, LimitKind}; @@ -136,10 +136,10 @@ impl PaneRecovery { } let mut out = Vec::new(); if generation > self.generation { - // A new spawn of the slot voids everything decided about the previous - // process. Landing in `Idle` either way; the only difference is that - // a relaunch we asked for counts as a resume that worked, while a - // respawn we did not ask for is a plain cancellation. + // A new spawn of the slot voids everything decided about the + // previous process. A relaunch we asked for counts as a resume + // that worked; a respawn we did not ask for is a plain cancellation. + // Either way the machine lands in `Idle`. self.generation = generation; self.alive = true; self.idle = false; @@ -168,7 +168,7 @@ impl PaneRecovery { } PluginEvent::PaneClosed { .. } | PluginEvent::UserInput { .. } => { // The slot is gone, or its human took it back. Either way this - // machine has no business acting on it again, and the attempt + // machine has no business acting on it again; the attempt // budget resets because the next episode is a fresh one. self.attempt = 0; out.extend(self.cancel()); @@ -219,10 +219,10 @@ impl PaneRecovery { return Vec::new(); } // The attempt budget is refunded only for an episode that had a real - // reset time to wait for. Those are bounded by the provider's own - // window, so refunding cannot spin. An episode with no known reset time - // keeps its count, which is what stops a pane that resumes cleanly and - // then immediately fails again from retrying forever. + // reset time to wait for — those are bounded by the provider's own + // window, so refunding cannot spin. An episode with no known reset + // time keeps its count, which is what stops a pane that resumes + // cleanly and then immediately fails again from retrying forever. if self.limit.as_ref().and_then(|l| l.resets_at).is_some() { self.attempt = 0; } diff --git a/plugins/nightcrow-recovery/src/state_clock.rs b/plugins/nightcrow-recovery/src/state_clock.rs index 517c2536..a5461773 100644 --- a/plugins/nightcrow-recovery/src/state_clock.rs +++ b/plugins/nightcrow-recovery/src/state_clock.rs @@ -1,8 +1,8 @@ //! Waiting: the half of the machine driven by the clock rather than by an event. //! //! Split out of `state.rs` for readability. Nothing here decides *what* to do -//! about a limit; it decides only how long to sit still first, and it is the one -//! place that can end a recovery by running out of attempts. +//! about a limit; it decides only how long to sit still first, and it is the +//! one place that can end a recovery by running out of attempts. use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RESUME_CONFIRM_SECS, RecoveryState}; use crate::protocol::PluginCommand; diff --git a/plugins/nightcrow-recovery/src/state_resume.rs b/plugins/nightcrow-recovery/src/state_resume.rs index 8d6697e0..b87400b6 100644 --- a/plugins/nightcrow-recovery/src/state_resume.rs +++ b/plugins/nightcrow-recovery/src/state_resume.rs @@ -3,10 +3,10 @@ //! Split out of `state.rs` to keep each file readable: `state.rs` owns time and //! transitions, this owns the one moment the plugin actually asks for something. //! -//! Everything here is written on the assumption that the host will refuse. A -//! refusal costs an attempt and nothing else, so the checks below exist to avoid -//! wasting attempts on requests that are obviously going to be rejected — not to -//! be the safety boundary. That boundary is the host's. +//! Everything here assumes the host will refuse: a refusal costs an attempt and +//! nothing else, so the checks below exist to avoid wasting attempts on +//! requests that are obviously going to be rejected — not to be the safety +//! boundary. That boundary is the host's. use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RecoveryState}; use crate::protocol::{MAX_INPUT_BYTES, PROTOCOL_VERSION, PluginCommand}; diff --git a/plugins/nightcrow-recovery/src/wait.rs b/plugins/nightcrow-recovery/src/wait.rs index d6699589..035c2c59 100644 --- a/plugins/nightcrow-recovery/src/wait.rs +++ b/plugins/nightcrow-recovery/src/wait.rs @@ -1,19 +1,15 @@ //! Waiting for a usage limit to reset, without trusting either clock alone. //! -//! A reset time arrives as an absolute unix second, which is the only form a -//! provider reports and the only form worth showing a human. But the wall clock -//! can be changed underneath a wait that lasts hours — an NTP correction, a -//! laptop returning from suspend, a user fixing their timezone — and a wait -//! driven purely by wall time would then either fire immediately (resuming into -//! a limit that has not cleared, burning an attempt) or never fire at all -//! (stranding the pane). +//! A reset time is an absolute unix second, but the wall clock can be changed +//! underneath a wait that lasts hours — an NTP correction, a laptop returning +//! from suspend. A wait driven purely by wall time would then fire early +//! (resuming into a limit that has not cleared, burning an attempt) or never +//! fire at all (stranding the pane). //! -//! So a wait keeps both: the absolute deadline, and a monotonic countdown of the -//! same length. Between two polls the two clocks must advance together; when -//! they disagree by more than [`JUMP_TOLERANCE_SECS`] the wall clock moved, and -//! the deadline is shifted by that amount so it stays fixed to the *new* clock. -//! The monotonic countdown then still has to elapse before the wait is over, so -//! a jump can neither shorten nor lengthen the real time spent waiting. +//! So a wait keeps both: the absolute deadline, and a monotonic countdown of +//! the same length. When the two disagree by more than [`JUMP_TOLERANCE_SECS`] +//! the wall clock moved, and the deadline is shifted by that amount so the +//! real time spent waiting can be neither shortened nor lengthened. use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -34,10 +30,9 @@ pub const MIN_WAIT_SECS: i64 = 15; /// Longest wait. Claude's longest documented window is seven days; eight days /// is past anything legitimate, so a deadline that would exceed it is clamped -/// rather than parking a pane indefinitely. -/// Must stay under the host's `PENDING_RELAUNCH_TTL` (nine days): the host -/// retires an exited pane's slot at that point, and a wait outlasting it would -/// end with nothing left to resume. +/// rather than parking a pane indefinitely. Must stay under the host's +/// `PENDING_RELAUNCH_TTL` (nine days): a wait outlasting that would end with +/// nothing left to resume. pub const MAX_WAIT_SECS: i64 = 8 * 24 * 60 * 60; /// Added to a reported reset time before resuming. diff --git a/src/app.rs b/src/app.rs index ccdfee76..791c2eb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::git::diff::{ChangedFile, RepoSnapshot, TrackingStatus}; +use crate::git::diff::{ChangedFile, RepoSnapshot}; mod app_impl; mod auto_follow; mod commit_log_apply; @@ -7,9 +7,13 @@ mod commit_log_pagination; mod diff_load; mod file_view_load; mod focus; +mod git_view_manager; mod interaction; +mod load_apply; +mod load_controller; mod log_nav; mod navigation; +mod repository_view; mod scroll; mod session_io; mod snapshot_io; @@ -17,20 +21,20 @@ mod terminal_ctrl; mod tree; mod tree_nav; -pub use crate::app::commit_log_pagination::CommitLogPagination; -pub use crate::runtime::snapshot::{SnapshotChannel, SnapshotMsg}; +#[cfg(test)] +pub use crate::runtime::snapshot::SnapshotChannel; +pub use crate::runtime::snapshot::SnapshotMsg; #[cfg(test)] pub use crate::runtime::terminal::PaneInfo; pub use crate::runtime::terminal::TerminalState; #[cfg(test)] pub(crate) use crate::runtime::terminal::strip_escape_sequences; -pub use crate::ui::diff_pane::{DiffPane, DiffPaneView}; +pub use crate::ui::diff_pane::DiffPaneView; pub use crate::ui::file_view::{FileViewKey, FileViewState}; -pub use crate::ui::log_view::LogView; -pub use crate::ui::status_view::StatusView; -pub use crate::ui::tree_view::TreeView; +pub use git_view_manager::GitViewManager; pub(crate) use interaction::{InteractionState, leader_label_of}; -use std::time::Instant; +#[cfg(test)] +pub use repository_view::RepositoryView; pub(crate) const LIST_PAGE_SIZE: usize = 10; pub(crate) const DIFF_PAGE_SIZE: usize = 20; @@ -69,63 +73,19 @@ pub enum Focus { Terminal, } -// Auto-follow state: idle timer + last-steered path. -#[derive(Default)] -pub struct AutoFollow { - pub last_manual_nav_at: Option, - pub followed_path: Option, -} - pub struct App { - pub mode: ViewMode, - pub status_view: StatusView, - pub diff: DiffPane, + pub(crate) git: GitViewManager, pub focus: Focus, pub notice: Option, - pub repo_path: String, - /// The daemon's opaque id for this repository, once attached. - /// - /// `None` when running without a daemon, and until the first set arrives. - pub repo_id: Option, - pub log_view: LogView, - pub tree_view: TreeView, pub terminal: TerminalState, - pub tracking: Option, - pub(crate) snapshot: SnapshotChannel, - // Set by `drain_snapshot` (every project), consumed by `poll_snapshot` - // (active only) — a background project's git work defers until its tab shows. - pub(crate) pending_snapshot: Option, - // Filesystem watcher for live tree refresh; active only in `ViewMode::Tree`. - pub(crate) tree_watch: crate::runtime::tree_watch::TreeWatcher, - // Watcher-touched directories not yet re-read. Filled by `drain_tree_watcher` - // (every project), consumed by `poll_tree_watcher` (active only). - pub(crate) tree_dirty: std::collections::BTreeSet, - // Set when events were dropped/unattributed: next refresh re-reads everything. - pub(crate) tree_dirty_all: bool, - // Saved selection waiting on the first snapshot. - pub(crate) pending_selection: Option<(String, usize)>, // Terminal focus, active pane, and fullscreen waiting on the panes. // // Panes belong to the session, so a fresh view has none until the daemon // reports them. A fresh launch starts with the default here and a restored // session replaces it with what was saved. pub(crate) pending_terminal: Option, - // Cached `git2::Repository` for sync loads. Opened lazily, invalidated in - // `change_repo`. The snapshot worker keeps its own handle (`!Send`). - pub(crate) repo_cache: Option, - pub cfg_agent_indicator: crate::config::AgentIndicatorConfig, - pub cfg_tree: crate::config::TreeConfig, - // Drop impl joins the worker so `change_repo` can't leak the old-repo fetch. - pub pagination: CommitLogPagination, - pub auto_follow: AutoFollow, // Mutually exclusive with `diff.fullscreen` and `terminal.fullscreen`. pub list_fullscreen: bool, - // `None` for detached HEAD / unborn branch / bare repo. - pub branch_name: Option, - // Ref chips and ahead/behind sets for the Log view. Rebuilt only when - // `last_refs_fingerprint` disagrees with the newest snapshot's. - pub log_decorations: crate::git::diff::LogDecorations, - pub(crate) last_refs_fingerprint: Option, pub(crate) interaction: InteractionState, } diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs index 4fe20ab6..f58fb205 100644 --- a/src/app/app_impl.rs +++ b/src/app/app_impl.rs @@ -1,6 +1,5 @@ -use crate::app::{App, AutoFollow, Focus, InteractionState, Notice, NoticeKind, ViewMode}; +use crate::app::{App, Focus, GitViewManager, InteractionState, Notice, NoticeKind}; use crate::backend::TerminalBackend; -use crate::runtime::snapshot::SnapshotChannel; use crossterm::event::KeyEvent; impl NoticeKind { @@ -47,10 +46,13 @@ impl App { } } - // NOT called for keys forwarded to a PTY: in a terminal pane every - // keystroke is passthrough, so dismissing on those would make a notice - // vanish the instant the user resumed typing. - // + // Only reached for keys nightcrow itself acts on (the dispatch gate + // excludes PTY passthrough), so typing in a terminal pane never blanks a + // notice. + pub fn dismiss_notice_on_app_input(&mut self) { + self.notice = None; + } + // Used when the press can no longer be paired with a real release (the // project is leaving the screen) but the PTY is still alive — dropping the // record would leave that program in a drag/selection state with no @@ -61,45 +63,21 @@ impl App { } } - pub fn dismiss_notice_on_app_input(&mut self) { - self.notice = None; - } - /// Build a project view on `repo_path`, with `backend` behind its terminal - /// panes. - /// - /// The backend comes from the caller because where the panes live is not - /// this type's decision: they belong to the session the daemon owns, and - /// only the client that connected to it can hand over the right end of that - /// connection. + /// panes. The backend comes from the caller: the panes belong to the + /// session the daemon owns, and only the client connected to it can hand + /// over the right end of that connection. pub fn new( repo_path: String, prompt_log: bool, leader: KeyEvent, backend: Box, ) -> Self { - let snapshot = SnapshotChannel::spawn(&repo_path); - let app = App { - mode: ViewMode::Status, - status_view: crate::ui::status_view::StatusView::default(), - diff: crate::ui::diff_pane::DiffPane::default(), + git: GitViewManager::new(repo_path), focus: Focus::FileList, notice: None, - repo_path, - repo_id: None, - log_view: crate::ui::log_view::LogView::default(), - tree_view: crate::ui::tree_view::TreeView::default(), terminal: crate::runtime::terminal::TerminalState::new(Some(backend), prompt_log), - tracking: None, - snapshot, - pending_snapshot: None, - // `main` upgrades to a live watcher after applying `[tree] live_watch`, - // so a `false` setting never spawns an OS watcher. - tree_watch: crate::runtime::tree_watch::TreeWatcher::disabled(), - tree_dirty: Default::default(), - tree_dirty_all: false, - pending_selection: None, // The fresh-launch rule: the panes are not here yet, and when they // arrive the input focus goes to them, as it did when this view // opened its own PTYs on the spot. A restored session overwrites @@ -108,22 +86,11 @@ impl App { focus: Some(Focus::Terminal), ..Default::default() }), - repo_cache: None, - cfg_agent_indicator: crate::config::AgentIndicatorConfig::default(), - cfg_tree: crate::config::TreeConfig::default(), - pagination: crate::app::commit_log_pagination::CommitLogPagination::with_config( - crate::config::LogConfig::default().commit_log_page_size, - crate::config::LogConfig::default().commit_log_prefetch_threshold, - ), - auto_follow: AutoFollow::default(), list_fullscreen: false, - branch_name: None, - log_decorations: Default::default(), - last_refs_fingerprint: None, interaction: InteractionState::new(leader), }; - tracing::info!(repo = %app.repo_path, "nightcrow started"); + tracing::info!(repo = %app.git.repo_path(), "nightcrow started"); app } @@ -131,10 +98,75 @@ impl App { // `Workspace::overlay_active`; both feed the key and mouse handlers so a // click can never reach behind a modal that swallows keystrokes. pub fn search_overlay_active(&self) -> bool { - self.status_view.search_active - || self.tree_view.search_active - || self.diff.search.active - || self.log_view.commit_search_active - || self.log_view.file_search_active + self.git.view.status.search_active + || self.git.view.tree.search_active + || self.git.view.diff.search.active + || self.git.view.log.commit_search_active + || self.git.view.log.file_search_active + } + + pub fn repository_path(&self) -> &str { + self.git.repo_path() + } + + pub fn repository_id(&self) -> Option<&str> { + self.git.repo_id() + } + + pub fn adopt_repository_id(&mut self, repo_id: String) { + self.git.adopt_repo_id(repo_id); + } + + pub fn mode(&self) -> crate::app::ViewMode { + self.git.view().mode() + } + + pub fn status_view(&self) -> &crate::ui::status_view::StatusView { + self.git.view().status() + } + + pub fn diff_pane(&self) -> &crate::ui::diff_pane::DiffPane { + self.git.view().diff() + } + + pub fn diff_pane_mut(&mut self) -> &mut crate::ui::diff_pane::DiffPane { + self.git.view_mut().diff_mut() + } + + pub fn log_view(&self) -> &crate::ui::log_view::LogView { + self.git.view().log() + } + + pub fn tree_view(&self) -> &crate::ui::tree_view::TreeView { + self.git.view().tree() + } + + pub fn tracking(&self) -> Option<&crate::git::diff::TrackingStatus> { + self.git.tracking.as_ref() + } + + pub fn branch_name(&self) -> Option<&str> { + self.git.branch_name.as_deref() + } + + pub fn log_decorations(&self) -> &crate::git::diff::LogDecorations { + &self.git.log_decorations + } + + pub fn agent_indicator_config(&self) -> &crate::config::AgentIndicatorConfig { + &self.git.agent_indicator + } + + pub(crate) fn configure_repository_views( + &mut self, + agent_indicator: crate::config::AgentIndicatorConfig, + tree: crate::config::TreeConfig, + ) { + self.git.agent_indicator = agent_indicator; + self.git.tree_config = tree; + } + + pub(crate) fn enable_tree_watcher(&mut self) { + self.git.view.tree_watch = crate::runtime::tree_watch::TreeWatcher::new(); } } diff --git a/src/app/auto_follow.rs b/src/app/auto_follow.rs index d9aa4878..54abe1af 100644 --- a/src/app/auto_follow.rs +++ b/src/app/auto_follow.rs @@ -3,19 +3,19 @@ use std::time::{Duration, Instant, SystemTime}; impl App { pub(crate) fn mark_user_navigated(&mut self) { - self.auto_follow.last_manual_nav_at = Some(Instant::now()); - self.auto_follow.followed_path = None; + self.git.view.auto_follow.last_manual_nav_at = Some(Instant::now()); + self.git.view.auto_follow.followed_path = None; } // Returns `true` when selection changed; caller refreshes the diff. pub(crate) fn try_auto_follow(&mut self) -> bool { - if !self.cfg_agent_indicator.enabled || !self.cfg_agent_indicator.auto_follow { + if !self.git.agent_indicator.enabled || !self.git.agent_indicator.auto_follow { return false; } - if self.focus != Focus::FileList || self.mode != ViewMode::Status { + if self.focus != Focus::FileList || self.git.view.mode != ViewMode::Status { return false; } - let idle = match self.auto_follow.last_manual_nav_at { + let idle = match self.git.view.auto_follow.last_manual_nav_at { None => true, Some(t) => t.elapsed() >= Duration::from_secs(2), }; @@ -29,34 +29,33 @@ impl App { if current_path.as_deref() == Some(target_path.as_str()) { return false; } - if self.auto_follow.followed_path.as_deref() == Some(target_path.as_str()) { + if self.git.view.auto_follow.followed_path.as_deref() == Some(target_path.as_str()) { return false; } let moved = self.select_status_file_by_path(&target_path); if moved { - self.auto_follow.followed_path = Some(target_path); + self.git.view.auto_follow.followed_path = Some(target_path); } moved } fn freshest_hot_path(&self) -> Option { - if self.status_view.hot_table.is_empty() { + if self.git.view.status.hot_table.is_empty() { return None; } let now = SystemTime::now(); - let window = Duration::from_secs(self.cfg_agent_indicator.hot_window_secs); + let window = Duration::from_secs(self.git.agent_indicator.hot_window_secs); let mut best: Option<(&str, SystemTime)> = None; for &idx in self.filtered_indices() { - let Some(file) = self.status_view.files.get(idx) else { + let Some(file) = self.git.view.status.files.get(idx) else { continue; }; - let Some(&mtime) = self.status_view.hot_table.get(&file.path) else { + let Some(&mtime) = self.git.view.status.hot_table.get(&file.path) else { continue; }; - // `duration_since` returns Err when `mtime > now` (clock skew on - // NFS, VMs, future-stamped files). Treating those as in-window - // would pin auto-follow to one bogus file forever; drop them - // entirely — recovery is automatic once the real clock catches up. + // Future mtimes (clock skew on NFS, VMs) would pin auto-follow to + // one bogus file forever; drop them — recovery is automatic once + // the real clock catches up. let Ok(age) = now.duration_since(mtime) else { continue; }; @@ -75,11 +74,17 @@ impl App { } fn select_status_file_by_path(&mut self, path: &str) -> bool { - if let Some(idx) = self.status_view.files.iter().position(|f| f.path == path) - && self.status_view.selected != idx + if let Some(idx) = self + .git + .view + .status + .files + .iter() + .position(|f| f.path == path) + && self.git.view.status.selected != idx { - self.status_view.selected = idx; - self.status_view.file_scroll_x = 0; + self.git.view.status.selected = idx; + self.git.view.status.file_scroll_x = 0; return true; } false diff --git a/src/app/commit_log_apply.rs b/src/app/commit_log_apply.rs index 02a47656..84018038 100644 --- a/src/app/commit_log_apply.rs +++ b/src/app/commit_log_apply.rs @@ -5,6 +5,9 @@ use super::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; impl App { pub(super) fn handle_commit_log_page_msg(&mut self, msg: CommitLogPageMsg) { + if msg.generation != self.git.commit_log.generation() { + return; + } match msg.kind { CommitLogFetchKind::Tail => self.apply_tail_page(msg), CommitLogFetchKind::Refresh { @@ -18,19 +21,19 @@ impl App { // Stale-result check: the worker was launched with `skip` equal to the // loaded count at the time. If the count has changed (HEAD refresh, // repo switch, etc.), the page no longer concatenates safely. - if msg.skip != self.log_view.loaded_count { - self.log_view.clear_pending(); + if msg.skip != self.git.view.log.loaded_count { + self.git.view.log.clear_pending(); return; } match msg.result { Ok(page) => { - self.log_view.append_page(page, msg.page_size); + self.git.view.log.append_page(page, msg.page_size); // Chain another fetch if the user is still near the new tail. self.maybe_prefetch_commit_log(); } Err(e) => { tracing::warn!(error = %e, "commit log page fetch failed"); - self.log_view.clear_pending(); + self.git.view.log.clear_pending(); } } } @@ -49,7 +52,7 @@ impl App { Ok(p) => p, Err(e) => { tracing::warn!(error = %e, "commit log refresh fetch failed"); - self.log_view.clear_pending(); + self.git.view.log.clear_pending(); return; } }; @@ -63,11 +66,11 @@ impl App { let page_is_short = page.len() < page_size; let can_prepend = prepend_idx.is_some_and(|idx| { let fresh_tail = &page[idx..]; - !self.log_view.commits.is_empty() - && fresh_tail.len() <= self.log_view.commits.len() + !self.git.view.log.commits.is_empty() + && fresh_tail.len() <= self.git.view.log.commits.len() && fresh_tail .iter() - .zip(self.log_view.commits.iter()) + .zip(self.git.view.log.commits.iter()) .all(|(fresh, cached)| fresh.oid == cached.oid) }); if let Some(idx) = prepend_idx @@ -75,62 +78,71 @@ impl App { { let mut new_head_commits: Vec<_> = page.into_iter().take(idx).collect(); let n_new = new_head_commits.len(); - new_head_commits.append(&mut self.log_view.commits); - self.log_view.commits = new_head_commits; - self.log_view.loaded_count = self.log_view.commits.len(); + new_head_commits.append(&mut self.git.view.log.commits); + self.git.view.log.commits = new_head_commits; + self.git.view.log.loaded_count = self.git.view.log.commits.len(); // `page_is_short` only describes the fresh first page; preserve // prior completion state and only promote to fully_loaded when the // new revwalk fits within one page. - if page_is_short && self.log_view.commits.len() <= page_size { - self.log_view.fully_loaded = true; + if page_is_short && self.git.view.log.commits.len() <= page_size { + self.git.view.log.fully_loaded = true; } - self.log_view.commit_width_cache.set(None); + self.git.view.log.commit_width_cache.set(None); // Prepend bypasses `set_commits`, so refresh the filter cache // manually so an active search query resolves against the new head. - self.log_view.recompute_commit_filter(); - self.log_view.clear_pending(); + self.git.view.log.recompute_commit_filter(); + self.git.view.log.clear_pending(); // Slide selection so the user keeps looking at the same commit. if let Some(prior_oid) = prior_selected_oid && let Some(pos) = self - .log_view + .git + .view + .log .commits .iter() .position(|c| c.oid == prior_oid) { - self.log_view.selected = pos; + self.git.view.log.selected = pos; } else { // `prior_selected_oid` was Some, so the cached list contained // that oid. If the lookup fails despite the list being a prefix // — corruption, or an unaccounted race — clamp to bounds so a // downstream `commits.get(selected)` lands on the tail instead // of returning None and clearing the diff pane. - self.log_view.selected = self - .log_view + self.git.view.log.selected = self + .git + .view + .log .selected .saturating_add(n_new) - .min(self.log_view.commits.len().saturating_sub(1)); + .min(self.git.view.log.commits.len().saturating_sub(1)); } } else { - self.log_view.set_commits_from_first_page(page, page_size); - self.log_view.selected = prior_selected_oid - .and_then(|oid| self.log_view.commits.iter().position(|c| c.oid == oid)) + self.git + .view + .log + .set_commits_from_first_page(page, page_size); + self.git.view.log.selected = prior_selected_oid + .and_then(|oid| self.git.view.log.commits.iter().position(|c| c.oid == oid)) .unwrap_or(0); } - self.log_view.commit_scroll_x = 0; + self.git.view.log.commit_scroll_x = 0; // Anchor the head-oid sentinel so ingest_snapshot doesn't immediately // trigger another refresh. - self.pagination.last_head_oid = self.log_view.commits.first().map(|c| c.oid); + self.git + .commit_log + .set_last_head_oid(self.git.view.log.commits.first().map(|c| c.oid)); // Drill-down survives only if the commit it was opened on is still in // the (possibly extended) list. - if self.log_view.drill_down + if self.git.view.log.drill_down && prior_selected_oid - .is_none_or(|oid| !self.log_view.commits.iter().any(|c| c.oid == oid)) + .is_none_or(|oid| !self.git.view.log.commits.iter().any(|c| c.oid == oid)) { - self.log_view.reset_drill_down(); + self.git.view.log.reset_drill_down(); } - if self.log_view.drill_down { + if self.git.view.log.drill_down { self.load_file_diff_for_log_file_selected(); } else { self.load_commit_diff_for_selected(); diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index 1721c4ba..d311e31f 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -30,6 +30,7 @@ pub(crate) enum CommitLogFetchKind { // as a stale-result check before appending — if the loaded count changed // between spawn and reply, the page is dropped. pub(crate) struct CommitLogPageMsg { + pub generation: u64, pub kind: CommitLogFetchKind, pub skip: usize, pub page_size: usize, @@ -37,11 +38,30 @@ pub(crate) struct CommitLogPageMsg { } impl App { + pub fn configure_commit_log(&mut self, page_size: usize, prefetch_threshold: usize) { + self.git.commit_log.configure(page_size, prefetch_threshold); + } + + #[cfg(test)] + pub(crate) fn commit_log_fetch_pending(&self) -> bool { + self.git.commit_log.fetch_pending() + } + + #[cfg(test)] + pub(crate) fn set_observed_head_for_test(&mut self, oid: Option) { + self.git.commit_log.set_last_head_oid(oid); + } + + #[cfg(test)] + pub(crate) fn observed_head_for_test(&self) -> Option { + self.git.commit_log.last_head_oid() + } + pub(crate) fn spawn_commit_log_page_fetch(&mut self, skip: usize) { - if self.log_view.fully_loaded { + if self.git.view.log.fully_loaded { return; } - if !self.log_view.mark_pending() { + if !self.git.view.log.mark_pending() { return; } self.launch_commit_log_worker(skip, CommitLogFetchKind::Tail); @@ -54,7 +74,7 @@ impl App { prior_selected_oid: Option, prior_head_oid: Option, ) { - if !self.log_view.mark_pending() { + if !self.git.view.log.mark_pending() { return; } self.launch_commit_log_worker( @@ -70,49 +90,53 @@ impl App { // receiver-drop already signals the worker to exit at next send, and an // old handle mid-`load_commit_log_page` must not stall the frame). fn launch_commit_log_worker(&mut self, skip: usize, kind: CommitLogFetchKind) { - drop(self.pagination.page_rx.take()); - self.pagination.handle.take(); - let page_size = self.pagination.page_size; - let repo_path = self.repo_path.clone(); + drop(self.git.commit_log.page_rx.take()); + self.git.commit_log.handle.take(); + let page_size = self.git.commit_log.page_size(); + let generation = self.git.commit_log.next_generation(); + let repo_path = self.git.repo_path.clone(); let (tx, rx) = mpsc::channel(); - self.pagination.page_rx = Some(rx); + self.git.commit_log.page_rx = Some(rx); let handle = thread::spawn(move || { let result = match Repository::discover(&repo_path) { Ok(repo) => load_commit_log_page(&repo, skip, page_size).map_err(|e| e.to_string()), Err(e) => Err(crate::git::format_discover_error(&e).to_string()), }; let _ = tx.send(CommitLogPageMsg { + generation, kind, skip, page_size, result, }); }); - self.pagination.handle = Some(handle); + self.git.commit_log.handle = Some(handle); } - pub(crate) fn poll_commit_log_page_fetch(&mut self) { - let Some(rx) = self.pagination.page_rx.as_ref() else { - return; + pub(crate) fn poll_commit_log_page_fetch(&mut self) -> bool { + let Some(rx) = self.git.commit_log.page_rx.as_ref() else { + return false; }; match rx.try_recv() { Ok(msg) => { - self.pagination.page_rx = None; - // Worker just sent → one statement from returning. A short - // timed join reaps the OS thread now; the timeout means a - // wedged worker still can't stall the frame. - if let Some(h) = self.pagination.handle.take() { + self.git.commit_log.page_rx = None; + // The worker just sent, so its next blocking point is gone; a + // short timed join reaps it now, and the timeout keeps a + // wedged worker from stalling the frame. + if let Some(h) = self.git.commit_log.handle.take() { try_timed_join(h, REAP_TIMEOUT); } self.handle_commit_log_page_msg(msg); + true } - Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Disconnected) => { - self.pagination.page_rx = None; - if let Some(h) = self.pagination.handle.take() { + self.git.commit_log.page_rx = None; + if let Some(h) = self.git.commit_log.handle.take() { try_timed_join(h, REAP_TIMEOUT); } - self.log_view.clear_pending(); + self.git.view.log.clear_pending(); + true } } } @@ -122,36 +146,37 @@ impl App { // worker's next `tx.send` to Err and the join completes in microseconds // in the common case; the timeout caps worst-case latency. pub(crate) fn cancel_commit_log_page_fetch(&mut self) { - drop(self.pagination.page_rx.take()); - if let Some(h) = self.pagination.handle.take() { + drop(self.git.commit_log.page_rx.take()); + self.git.commit_log.next_generation(); + if let Some(h) = self.git.commit_log.handle.take() { try_timed_join(h, REAP_TIMEOUT); } - self.log_view.clear_pending(); + self.git.view.log.clear_pending(); } pub(crate) fn maybe_prefetch_commit_log(&mut self) { - if self.mode != ViewMode::Log { + if self.git.view.mode != ViewMode::Log { return; } - if self.log_view.drill_down { + if self.git.view.log.drill_down { return; } // Pause tail prefetch while the commit-list search bar is open: a new // page arriving mid-search would shift the filter cache and disturb // the user's view. The gate is lifted by `cancel_log_search` / // `confirm_log_search`, which re-call this helper on the way out. - if self.log_view.commit_search_active { + if self.git.view.log.commit_search_active { return; } - if self.log_view.commits.is_empty() { + if self.git.view.log.commits.is_empty() { return; } - if self.log_view.pending_fetch || self.log_view.fully_loaded { + if self.git.view.log.pending_fetch || self.git.view.log.fully_loaded { return; } - let loaded = self.log_view.loaded_count; - let threshold = self.pagination.prefetch_threshold; - if self.log_view.selected + threshold >= loaded { + let loaded = self.git.view.log.loaded_count; + let threshold = self.git.commit_log.prefetch_threshold(); + if self.git.view.log.selected + threshold >= loaded { self.spawn_commit_log_page_fetch(loaded); } } @@ -160,7 +185,7 @@ impl App { #[cfg(test)] pub(crate) fn flush_commit_log_fetch_for_test(&mut self, timeout: std::time::Duration) { let start = std::time::Instant::now(); - while self.log_view.pending_fetch { + while self.git.view.log.pending_fetch || self.commit_log_fetch_pending() { if start.elapsed() > timeout { panic!("commit log fetch did not complete within {:?}", timeout); } diff --git a/src/app/commit_log_pagination.rs b/src/app/commit_log_pagination.rs index f8627c90..9113ad3b 100644 --- a/src/app/commit_log_pagination.rs +++ b/src/app/commit_log_pagination.rs @@ -11,19 +11,20 @@ use super::commit_log_fetch::CommitLogPageMsg; // worker's `tx.send` fail, then the JoinHandle is awaited so `change_repo` // can't leak the old-repo worker. #[derive(Default)] -pub struct CommitLogPagination { - pub page_size: usize, - pub prefetch_threshold: usize, +pub struct CommitLogController { + page_size: usize, + prefetch_threshold: usize, pub(crate) page_rx: Option>, // `cancel_commit_log_page_fetch` deliberately does NOT join here: the UI // tick can't wait for a mid-`load_commit_log_page` worker. Receiver-drop // already makes the reply fail; detaching is safe (worst case: one extra // OS thread until it finishes). pub(crate) handle: Option>, - pub(crate) last_head_oid: Option, + last_head_oid: Option, + generation: u64, } -impl CommitLogPagination { +impl CommitLogController { // `..Default::default()` can't be used: the type implements `Drop`. pub fn with_config(page_size: usize, prefetch_threshold: usize) -> Self { Self { @@ -32,11 +33,41 @@ impl CommitLogPagination { page_rx: None, handle: None, last_head_oid: None, + generation: 0, } } + + pub fn configure(&mut self, page_size: usize, prefetch_threshold: usize) { + self.page_size = page_size; + self.prefetch_threshold = prefetch_threshold; + } + + pub(crate) fn page_size(&self) -> usize { + self.page_size + } + pub(crate) fn prefetch_threshold(&self) -> usize { + self.prefetch_threshold + } + pub(crate) fn last_head_oid(&self) -> Option { + self.last_head_oid + } + pub(crate) fn set_last_head_oid(&mut self, oid: Option) { + self.last_head_oid = oid; + } + pub(crate) fn next_generation(&mut self) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.generation + } + pub(crate) fn generation(&self) -> u64 { + self.generation + } + #[cfg(test)] + pub(crate) fn fetch_pending(&self) -> bool { + self.page_rx.is_some() + } } -impl Drop for CommitLogPagination { +impl Drop for CommitLogController { fn drop(&mut self) { // Drop receiver first so the worker's next `tx.send` fails and the // loop exits; then bounded-join so a stuck libgit2 call can't freeze @@ -47,3 +78,29 @@ impl Drop for CommitLogPagination { } } } + +#[cfg(test)] +mod tests { + use super::CommitLogController; + + #[test] + fn generation_advances_for_each_request_and_cancel() { + let mut controller = CommitLogController::with_config(50, 10); + + let first = controller.next_generation(); + let second = controller.next_generation(); + + assert_ne!(first, second); + assert_eq!(controller.generation(), second); + } + + #[test] + fn configuration_is_exposed_without_worker_internals() { + let mut controller = CommitLogController::with_config(50, 10); + controller.configure(25, 5); + + assert_eq!(controller.page_size(), 25); + assert_eq!(controller.prefetch_threshold(), 5); + assert!(!controller.fetch_pending()); + } +} diff --git a/src/app/diff_load.rs b/src/app/diff_load.rs index ce66b9ac..a2ecc1e8 100644 --- a/src/app/diff_load.rs +++ b/src/app/diff_load.rs @@ -1,12 +1,15 @@ +use super::load_controller::{DiffLoadMode, DiffTarget}; use super::{App, DiffPaneView, FileViewState, NoticeKind, ViewMode}; -use crate::git::diff::{DiffHunk, load_file_diff, parse_hunk_new_start}; +use crate::git::diff::{DiffHunk, parse_hunk_new_start}; // Replaces a prior 3-flag signature where `reset_scroll`/`keep_scroll` were // hard to parse at call sites. pub(crate) enum DiffApply<'a> { Reset, + ResetPreservingFile, KeepScroll(usize), ResetWithTitle(&'a str), + ResetWithTitlePreservingFile(&'a str), } impl App { @@ -22,12 +25,12 @@ impl App { &mut self, f: impl FnOnce(&git2::Repository) -> anyhow::Result, ) -> anyhow::Result { - if self.repo_cache.is_none() { - let repo = git2::Repository::discover(self.repo_path.as_str()) + if self.git.repo_cache.is_none() { + let repo = git2::Repository::discover(self.git.repo_path.as_str()) .map_err(|e| anyhow::anyhow!("{}", crate::git::format_discover_error(&e)))?; - self.repo_cache = Some(repo); + self.git.repo_cache = Some(repo); } - let result = f(self.repo_cache.as_ref().unwrap()); + let result = f(self.git.repo_cache.as_ref().unwrap()); if let Err(ref e) = result && let Some(git_err) = e.downcast_ref::() && matches!( @@ -35,7 +38,7 @@ impl App { git2::ErrorClass::Os | git2::ErrorClass::Repository ) { - self.repo_cache = None; + self.git.repo_cache = None; } result } @@ -43,25 +46,22 @@ impl App { pub(crate) fn refresh_diff(&mut self, reset_scroll: bool) { // Only Status shows a file diff; Log and Tree drive the diff via their // own loaders and must not have a status diff loaded over them. - if self.mode != ViewMode::Status { + if self.git.view.mode != ViewMode::Status { return; } - let previous_scroll = self.diff.scroll; + let previous_scroll = self.git.view.diff.scroll; let Some(path) = self.selected_filtered_status_path() else { self.clear_diff_state(); return; }; - let result = self.with_repo(|repo| load_file_diff(repo, &path)); - if let Err(e) = &result { - tracing::warn!(error = %e, file = %path, "failed to load diff"); - self.raise_notice(NoticeKind::Diff, e.to_string()); - } let mode = if reset_scroll { - DiffApply::Reset + DiffLoadMode::Reset } else { - DiffApply::KeepScroll(previous_scroll) + DiffLoadMode::KeepScroll(previous_scroll) }; - self.apply_diff_result(result, mode); + self.git + .load_controller + .request_diff(&self.git.repo_path, DiffTarget::Status(path), mode); } pub(crate) fn apply_diff_result( @@ -69,76 +69,92 @@ impl App { result: anyhow::Result>, mode: DiffApply<'_>, ) { - let reset_scroll = matches!(mode, DiffApply::Reset | DiffApply::ResetWithTitle(_)); + let reset_scroll = matches!( + mode, + DiffApply::Reset + | DiffApply::ResetPreservingFile + | DiffApply::ResetWithTitle(_) + | DiffApply::ResetWithTitlePreservingFile(_) + ); + let preserve_file = matches!( + mode, + DiffApply::ResetPreservingFile | DiffApply::ResetWithTitlePreservingFile(_) + ); match result { Ok(hunks) => { self.clear_notice(NoticeKind::Diff); - self.diff.hunks = hunks; - self.diff.rebuild_lower_cache(); + self.git.view.diff.set_hunks(hunks); match mode { - DiffApply::Reset | DiffApply::ResetWithTitle(_) => { - self.diff.scroll = 0; - self.diff.scroll_x = 0; - self.diff.search.cursor = 0; - self.invalidate_file_view(); + DiffApply::Reset + | DiffApply::ResetPreservingFile + | DiffApply::ResetWithTitle(_) + | DiffApply::ResetWithTitlePreservingFile(_) => { + self.git.view.diff.scroll = 0; + self.git.view.diff.scroll_x = 0; + self.git.view.diff.search.cursor = 0; + if !preserve_file { + self.invalidate_file_view(); + } } DiffApply::KeepScroll(prev) => { // Clamp against the new (possibly shorter) diff so // scroll isn't left out of range for the next keystroke. - self.diff.scroll = prev.min(self.diff.max_scroll()); + self.git.view.diff.scroll = prev.min(self.git.view.diff.max_scroll()); // The file-overlay anchor was computed against the // previous hunks; recompute so the open file pane stays // aligned with the replaced diff. - if self.diff.file_view.key.is_some() { - self.diff.file_view.anchor_line = self.anchor_for_current_diff(); + if self.git.view.diff.file_view.key.is_some() { + self.git.view.diff.file_view.anchor_line = + self.anchor_for_current_diff(); } } } - if !self.diff.search.query.is_empty() { - self.diff.recompute_matches(reset_scroll); + if !self.git.view.diff.search.query.is_empty() { + self.git.view.diff.recompute_matches(reset_scroll); } } Err(_) => { // KeepScroll error (in-place refresh) keeps the prior diff: // usually a transient race (mid-rename, slow index update) and // clearing would flash an empty pane and dangle `scroll`. - if !matches!(mode, DiffApply::KeepScroll(_)) { + if !matches!(mode, DiffApply::KeepScroll(_)) && !preserve_file { self.clear_diff_state(); } } } // Title belongs to the surrounding view, not the diff state — set it // last so it survives both success and failure. - if let DiffApply::ResetWithTitle(title) = mode { - self.log_view.diff_title = title.to_string(); + if let DiffApply::ResetWithTitle(title) | DiffApply::ResetWithTitlePreservingFile(title) = + mode + { + self.git.view.log.diff_title = title.to_string(); } } pub(crate) fn clear_diff_state(&mut self) { - self.diff.hunks.clear(); - self.diff.hunks_lines_lower.clear(); - self.diff.line_highlights.clear(); - self.diff.cached_hunk_syntax.clear(); + self.git.load_controller.cancel_diff(); + self.git.view.diff.set_hunks(Vec::new()); // Drop the entire search state, not just the match list: keeping the // query alive after a content-discarding clear would leave a ghost // `[0/0]` counter and apply the previous file's query to unrelated // content on the next load. - self.diff.search.clear(); - self.diff.scroll = 0; - self.diff.scroll_x = 0; + self.git.view.diff.search.clear(); + self.git.view.diff.scroll = 0; + self.git.view.diff.scroll_x = 0; self.invalidate_file_view(); } pub(crate) fn invalidate_file_view(&mut self) { - self.diff.view = DiffPaneView::Diff; - self.diff.file_view = FileViewState::default(); + self.git.load_controller.cancel_file(); + self.git.view.diff.view = DiffPaneView::Diff; + self.git.view.diff.file_view = FileViewState::default(); } pub(crate) fn anchor_for_current_diff(&self) -> Option { - let scroll = self.diff.scroll; + let scroll = self.git.view.diff.scroll; let mut offset = 0usize; let mut chosen = None; - for h in &self.diff.hunks { + for h in self.git.view.diff.hunks() { if let Some(n) = parse_hunk_new_start(&h.header) { chosen = Some(n); } @@ -154,11 +170,13 @@ impl App { // the worker replies so the UI tick never blocks on a 100-commit revwalk. pub(crate) fn refresh_commit_log_after_head_change(&mut self) { let prior_selected_oid = self - .log_view + .git + .view + .log .commits - .get(self.log_view.selected) + .get(self.git.view.log.selected) .map(|c| c.oid); - let prior_head_oid = self.log_view.commits.first().map(|c| c.oid); + let prior_head_oid = self.git.view.log.commits.first().map(|c| c.oid); // Any in-flight worker was launched against state that no longer // matches; drop it so only this refresh's reply can land. diff --git a/src/app/file_view_load.rs b/src/app/file_view_load.rs index 2532518e..f3b9646e 100644 --- a/src/app/file_view_load.rs +++ b/src/app/file_view_load.rs @@ -1,36 +1,43 @@ -use super::diff_load::DiffApply; -use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; -use crate::git::diff::{ - load_commit_diff, load_commit_file_blob, load_commit_file_diff, load_workdir_file, -}; +use super::load_controller::{DiffLoadMode, DiffTarget}; +use super::{App, DiffPaneView, FileViewKey, FileViewState, ViewMode}; impl App { pub(crate) fn current_file_view_key(&self) -> Option { - match self.mode { + match self.git.view.mode { ViewMode::Status => { let path = self.selected_filtered_status_file()?.path.clone(); Some(FileViewKey::Status(path)) } ViewMode::Tree => { let row = self - .tree_view + .git + .view + .tree .visible_rows() .into_iter() - .nth(self.tree_view.selected)?; + .nth(self.git.view.tree.selected)?; if row.is_dir { return None; } Some(FileViewKey::Status(row.path)) } ViewMode::Log => { - if !self.log_view.drill_down { + if !self.git.view.log.drill_down { return None; } - let oid = self.log_view.commits.get(self.log_view.selected)?.oid; + let oid = self + .git + .view + .log + .commits + .get(self.git.view.log.selected)? + .oid; let file = self - .log_view + .git + .view + .log .commit_files - .get(self.log_view.file_selected)?; + .get(self.git.view.log.file_selected)?; Some(FileViewKey::Commit { oid, path: file.path.clone(), @@ -41,71 +48,48 @@ impl App { } pub(crate) fn load_file_view(&mut self, key: FileViewKey) { - let result = match &key { - FileViewKey::Status(path) => self.with_repo(|repo| load_workdir_file(repo, path)), - FileViewKey::Commit { - oid, path, status, .. - } => { - let oid = *oid; - let status = *status; - self.with_repo(|repo| load_commit_file_blob(repo, oid, path, status)) - } - }; let anchor = self.anchor_for_current_diff(); - let mut fv = FileViewState { - key: Some(key), + self.git.view.diff.file_view = FileViewState { + key: Some(key.clone()), anchor_line: anchor, ..Default::default() }; - match result { - Ok(content) => { - fv.set_content(content); - // 2 lines of context above the hunk's new-side start (1-based - // → 0-based). Clamp so a stale anchor past the current file - // length doesn't open on a blank region. - let initial = anchor - .map(|n| n.saturating_sub(1).saturating_sub(2)) - .unwrap_or(0); - fv.scroll = initial.min(fv.max_scroll()); - } - Err(e) => { - fv.error = Some(e.to_string()); - } - } - self.diff.file_view = fv; + self.git + .load_controller + .request_file(&self.git.repo_path, key, anchor); } // Mirrors the gates in `toggle_diff_file_view` so the hint bar only // advertises `v: view file` when a press would act. pub(crate) fn can_open_file_view(&self) -> bool { - self.mode != ViewMode::Tree && self.current_file_view_key().is_some() + self.git.view.mode != ViewMode::Tree && self.current_file_view_key().is_some() } pub fn toggle_diff_file_view(&mut self) { // Tree mode's right pane is always the raw file preview; `v`/`s` are no-ops. - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { return; } - if self.diff.view == DiffPaneView::File { - self.diff.search.clear(); - self.diff.view = DiffPaneView::Diff; + if self.git.view.diff.view == DiffPaneView::File { + self.git.view.diff.search.clear(); + self.git.view.diff.view = DiffPaneView::Diff; return; } let Some(key) = self.current_file_view_key() else { return; }; - if self.diff.file_view.key.as_ref() != Some(&key) { + if self.git.view.diff.file_view.key.as_ref() != Some(&key) { self.load_file_view(key); } - self.diff.search.clear(); - self.diff.view = DiffPaneView::File; + self.git.view.diff.search.clear(); + self.git.view.diff.view = DiffPaneView::File; } pub fn toggle_diff_split_view(&mut self) { - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { return; } - self.diff.view = if self.diff.view == DiffPaneView::Split { + self.git.view.diff.view = if self.git.view.diff.view == DiffPaneView::Split { DiffPaneView::Diff } else { DiffPaneView::Split @@ -118,33 +102,29 @@ impl App { /// while wrapping, so leaving it set would strand a stale offset that /// silently reappears the moment wrapping is turned back off. pub fn toggle_diff_wrap(&mut self) { - self.diff.wrap = !self.diff.wrap; - if self.diff.wrap { - self.diff.scroll_x = 0; - self.diff.file_view.scroll_x = 0; + self.git.view.diff.wrap = !self.git.view.diff.wrap; + if self.git.view.diff.wrap { + self.git.view.diff.scroll_x = 0; + self.git.view.diff.file_view.scroll_x = 0; } } /// Step to the next display: unified → split → file → unified. /// - /// `v` and `s` each toggle one view against the unified default, which - /// leaves the third one undiscoverable unless you already know it exists. - /// One key that walks all three makes the set visible; the direct toggles - /// stay for jumping straight to a known view. - /// - /// The file step is skipped when there is nothing to open (no selection, or - /// a commit whose file cannot be resolved) rather than being a dead press — - /// the same gate `can_open_file_view` puts on `v`. + /// `v` and `s` each toggle one view against the unified default, leaving + /// the third undiscoverable; one key that walks all three makes the set + /// visible. The file step is skipped when there is nothing to open — the + /// same gate `can_open_file_view` puts on `v`. pub fn cycle_diff_view(&mut self) { // Tree mode's right pane is always the raw file preview, so there is // no cycle to walk — matching `v`/`s`. - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { return; } - match self.diff.view { + match self.git.view.diff.view { DiffPaneView::Diff => self.toggle_diff_split_view(), DiffPaneView::Split => { - self.diff.view = DiffPaneView::Diff; + self.git.view.diff.view = DiffPaneView::Diff; if self.can_open_file_view() { self.toggle_diff_file_view(); } @@ -154,49 +134,53 @@ impl App { } pub(crate) fn load_commit_diff_for_selected(&mut self) { - let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { + let (oid, title) = match self.git.view.log.commits.get(self.git.view.log.selected) { Some(entry) => (entry.oid, entry.to_string()), None => { self.clear_diff_state(); - self.log_view.diff_title.clear(); + self.git.view.log.diff_title.clear(); return; } }; - let result = self.with_repo(|repo| load_commit_diff(repo, oid)); - if let Err(e) = &result { - tracing::warn!(error = %e, "failed to load commit diff"); - self.raise_notice(NoticeKind::Diff, e.to_string()); - } - self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); + self.git.view.log.diff_title = title.clone(); + self.git.load_controller.request_diff( + &self.git.repo_path, + DiffTarget::Commit(oid), + DiffLoadMode::ResetWithTitle(title), + ); } pub(crate) fn load_file_diff_for_log_file_selected(&mut self) { let Some((oid, short_id, commit_title)) = self - .log_view + .git + .view + .log .commits - .get(self.log_view.selected) + .get(self.git.view.log.selected) .map(|c| (c.oid, c.short_id.clone(), c.to_string())) else { self.clear_diff_state(); - self.log_view.diff_title.clear(); + self.git.view.log.diff_title.clear(); return; }; let Some(path) = self - .log_view + .git + .view + .log .commit_files - .get(self.log_view.file_selected) + .get(self.git.view.log.file_selected) .map(|f| f.path.clone()) else { self.clear_diff_state(); - self.log_view.diff_title = commit_title; + self.git.view.log.diff_title = commit_title; return; }; let title = format!("{short_id} {path}"); - let result = self.with_repo(|repo| load_commit_file_diff(repo, oid, &path)); - if let Err(e) = &result { - tracing::warn!(error = %e, file = %path, "failed to load commit file diff"); - self.raise_notice(NoticeKind::Diff, e.to_string()); - } - self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); + self.git.view.log.diff_title = title.clone(); + self.git.load_controller.request_diff( + &self.git.repo_path, + DiffTarget::CommitFile { oid, path }, + DiffLoadMode::ResetWithTitle(title), + ); } } diff --git a/src/app/focus.rs b/src/app/focus.rs index 44ff1aa9..80e1c812 100644 --- a/src/app/focus.rs +++ b/src/app/focus.rs @@ -4,49 +4,48 @@ use crate::runtime::terminal::TerminalFullscreen; impl App { pub fn toggle_mode(&mut self) { self.clear_diff_state(); - let from = self.mode; + let from = self.git.view.mode; // Terminal/diff fullscreen hides the list pane, so a mode toggle there // would flip state invisibly. Reveal the result with `focus_list`'s // policy. `list_fullscreen` is excluded: it already renders the mode's // active list, so the swap is visible and the zoom should survive. - let reveal_after_toggle = self.terminal.fullscreen.fills_body() || self.diff.fullscreen; - match self.mode { + let reveal_after_toggle = + self.terminal.fullscreen.fills_body() || self.git.view.diff.fullscreen; + match self.git.view.mode { ViewMode::Status | ViewMode::Tree => { // Leaving Tree: drop filesystem watches so descriptors aren't // held while the tree is hidden. Tree re-entry re-syncs them. - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { self.clear_tree_watches(); } self.enter_log_mode(); } ViewMode::Log => { - self.mode = ViewMode::Status; - self.log_view.reset_drill_down(); + self.git.view.set_mode(ViewMode::Status); + self.git.view.log.reset_drill_down(); self.refresh_diff(true); } } if reveal_after_toggle { self.focus_list(); } - tracing::debug!(from = ?from, to = ?self.mode, "view mode toggled"); + tracing::debug!(from = ?from, to = ?self.git.view.mode, "view mode toggled"); } - // Reuses cached commit pages when they still match the latest HEAD; - // otherwise refreshes in the background. fn enter_log_mode(&mut self) { - self.mode = ViewMode::Log; - self.log_view.reset_drill_down(); - self.log_view.commit_scroll_x = 0; + self.git.view.set_mode(ViewMode::Log); + self.git.view.log.reset_drill_down(); + self.git.view.log.commit_scroll_x = 0; // Reuse cached pages on re-entry only while they still match the // latest HEAD observed by the snapshot worker. Status mode doesn't // refresh the hidden commit list, so a HEAD change there must // invalidate the cache on the next entry. - let cached_head = self.log_view.commits.first().map(|c| c.oid); - let cache_matches_head = - !self.log_view.commits.is_empty() && cached_head == self.pagination.last_head_oid; - if !self.log_view.commits.is_empty() && !cache_matches_head { + let cached_head = self.git.view.log.commits.first().map(|c| c.oid); + let cache_matches_head = !self.git.view.log.commits.is_empty() + && cached_head == self.git.commit_log.last_head_oid(); + if !self.git.view.log.commits.is_empty() && !cache_matches_head { self.refresh_commit_log_after_head_change(); - } else if self.log_view.commits.is_empty() { + } else if self.git.view.log.commits.is_empty() { // First entry with no cached pages: spawn a background refresh // instead of loading on the UI thread. The diff pane stays empty // until `apply_refresh_page` loads the commit diff for the fresh @@ -62,9 +61,10 @@ impl App { // ` b` enters Tree from Status/Log and returns to Status from Tree. // Mirrors `toggle_mode`'s fullscreen-reveal policy. pub fn toggle_tree_mode(&mut self) { - let from = self.mode; - let reveal_after_toggle = self.terminal.fullscreen.fills_body() || self.diff.fullscreen; - if self.mode == ViewMode::Tree { + let from = self.git.view.mode; + let reveal_after_toggle = + self.terminal.fullscreen.fills_body() || self.git.view.diff.fullscreen; + if self.git.view.mode == ViewMode::Tree { self.exit_tree_to_status(); } else { self.enter_tree_mode(); @@ -72,11 +72,11 @@ impl App { if reveal_after_toggle { self.focus_list(); } - tracing::debug!(from = ?from, to = ?self.mode, "tree mode toggled"); + tracing::debug!(from = ?from, to = ?self.git.view.mode, "tree mode toggled"); } pub fn cycle_focus_forward(&mut self) { - if self.diff.fullscreen || self.list_fullscreen { + if self.git.view.diff.fullscreen || self.list_fullscreen { return; } if self.terminal.fullscreen.fills_body() { @@ -112,7 +112,7 @@ impl App { } pub fn cycle_focus_backward(&mut self) { - if self.diff.fullscreen || self.list_fullscreen { + if self.git.view.diff.fullscreen || self.list_fullscreen { return; } if self.terminal.fullscreen.fills_body() { @@ -166,7 +166,7 @@ impl App { self.terminal.fullscreen = next; if next.fills_body() { self.focus = Focus::Terminal; - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.list_fullscreen = false; } // `max_visible()` just changed (e.g. 8 → 1 entering Zoom), so re-clamp @@ -175,14 +175,14 @@ impl App { } pub fn toggle_diff_fullscreen(&mut self) { - self.set_diff_fullscreen(!self.diff.fullscreen); + self.set_diff_fullscreen(!self.git.view.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.git.view.diff.fullscreen = on; + if self.git.view.diff.fullscreen { self.focus = Focus::DiffViewer; self.terminal.fullscreen = TerminalFullscreen::Off; self.list_fullscreen = false; @@ -193,7 +193,7 @@ impl App { self.list_fullscreen = !self.list_fullscreen; if self.list_fullscreen { self.focus = Focus::FileList; - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.terminal.fullscreen = TerminalFullscreen::Off; } } @@ -202,7 +202,7 @@ impl App { // so a user with the list already maximized keeps that view on F1. pub fn focus_list(&mut self) { self.focus = Focus::FileList; - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.terminal.fullscreen = TerminalFullscreen::Off; } diff --git a/src/app/git_view_manager.rs b/src/app/git_view_manager.rs new file mode 100644 index 00000000..c2fc68f9 --- /dev/null +++ b/src/app/git_view_manager.rs @@ -0,0 +1,94 @@ +use crate::config::{AgentIndicatorConfig, LogConfig, TreeConfig}; +use crate::git::diff::{LogDecorations, TrackingStatus}; +use crate::runtime::snapshot::{SnapshotChannel, SnapshotMsg}; +#[cfg(test)] +use crate::runtime::tree_watch::TreeWatcher; + +use super::commit_log_pagination::CommitLogController; +use super::load_controller::LoadController; +use super::repository_view::RepositoryView; + +pub struct GitViewManager { + pub(crate) repo_path: String, + pub(crate) repo_id: Option, + pub(crate) view: RepositoryView, + pub(crate) repo_cache: Option, + pub(crate) snapshot: SnapshotChannel, + pub(crate) pending_snapshot: Option, + pub(crate) commit_log: CommitLogController, + pub(crate) branch_name: Option, + pub(crate) tracking: Option, + pub(crate) log_decorations: LogDecorations, + pub(crate) last_refs_fingerprint: Option, + pub(crate) load_controller: LoadController, + pub(crate) agent_indicator: AgentIndicatorConfig, + pub(crate) tree_config: TreeConfig, +} + +impl GitViewManager { + pub fn new(repo_path: String) -> Self { + let snapshot = SnapshotChannel::spawn(&repo_path); + Self::from_parts(repo_path, snapshot, RepositoryView::default()) + } + + fn from_parts(repo_path: String, snapshot: SnapshotChannel, view: RepositoryView) -> Self { + let log = LogConfig::default(); + Self { + repo_path, + repo_id: None, + view, + repo_cache: None, + snapshot, + pending_snapshot: None, + commit_log: CommitLogController::with_config( + log.commit_log_page_size, + log.commit_log_prefetch_threshold, + ), + branch_name: None, + tracking: None, + log_decorations: LogDecorations::default(), + last_refs_fingerprint: None, + load_controller: LoadController::new(), + agent_indicator: AgentIndicatorConfig::default(), + tree_config: TreeConfig::default(), + } + } + + #[cfg(test)] + pub(crate) fn from_test_parts( + repo_path: String, + snapshot: SnapshotChannel, + tree_watch: TreeWatcher, + ) -> Self { + Self::from_parts( + repo_path, + snapshot, + RepositoryView::default().with_tree_watcher(tree_watch), + ) + } + + pub fn repo_path(&self) -> &str { + &self.repo_path + } + + pub fn repo_id(&self) -> Option<&str> { + self.repo_id.as_deref() + } + + pub fn adopt_repo_id(&mut self, repo_id: String) { + self.repo_id = Some(repo_id); + } + + pub fn view(&self) -> &RepositoryView { + &self.view + } + + pub fn view_mut(&mut self) -> &mut RepositoryView { + &mut self.view + } + + #[cfg(test)] + pub(crate) fn pending_snapshot(&self) -> Option<&SnapshotMsg> { + self.pending_snapshot.as_ref() + } +} diff --git a/src/app/load_apply.rs b/src/app/load_apply.rs new file mode 100644 index 00000000..22a68809 --- /dev/null +++ b/src/app/load_apply.rs @@ -0,0 +1,227 @@ +use std::sync::mpsc; + +use super::diff_load::DiffApply; +use super::load_controller::{DiffLoadMode, DiffTarget}; +use super::{App, DiffPaneView, FileViewState, NoticeKind, ViewMode}; +use crate::git::diff::{GitLoadPayload, GitLoadReply, LoadLane}; + +impl App { + pub(crate) fn poll_git_loads(&mut self) -> bool { + let mut received = false; + loop { + match self.git.load_controller.worker.try_recv() { + Ok(reply) => { + received = true; + self.apply_git_load(reply); + } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => { + return received; + } + } + } + } + + fn apply_git_load(&mut self, reply: GitLoadReply) { + if reply.request.repo != self.git.repo_path { + return; + } + match reply.request.operation.lane() { + LoadLane::Diff => self.apply_diff_reply(reply), + LoadLane::File => self.apply_file_reply(reply), + LoadLane::CommitFiles => self.apply_commit_files_reply(reply), + LoadLane::Decorations => self.apply_decorations_reply(reply), + } + } + + fn apply_diff_reply(&mut self, reply: GitLoadReply) { + let Some(intent) = self.git.load_controller.diff.as_ref() else { + return; + }; + if intent.generation != reply.request.generation || intent.repo != reply.request.repo { + return; + } + let intent = self.git.load_controller.diff.take().unwrap(); + if !self.diff_target_is_current(&intent.target) { + return; + } + let result = match reply.result { + Ok(GitLoadPayload::Diff(hunks)) => Ok(hunks), + Ok(_) => return, + Err(error) => { + tracing::warn!(error = %error, "background diff load failed"); + self.raise_notice(NoticeKind::Diff, error.clone()); + Err(anyhow::anyhow!(error)) + } + }; + let current_file_key_matches = self + .current_file_view_key() + .as_ref() + .is_some_and(|key| self.git.view.diff.file_view.key.as_ref() == Some(key)); + let preserve_file = current_file_key_matches + && (self + .git + .load_controller + .file_generation() + .is_some_and(|generation| generation > intent.generation) + || self.git.view.diff.view == DiffPaneView::File); + match intent.mode { + DiffLoadMode::Reset if preserve_file => { + self.apply_diff_result(result, DiffApply::ResetPreservingFile); + } + DiffLoadMode::Reset => self.apply_diff_result(result, DiffApply::Reset), + DiffLoadMode::KeepScroll(scroll) => { + self.apply_diff_result(result, DiffApply::KeepScroll(scroll)); + } + DiffLoadMode::ResetWithTitle(title) if preserve_file => { + self.apply_diff_result(result, DiffApply::ResetWithTitlePreservingFile(&title)); + } + DiffLoadMode::ResetWithTitle(title) => { + self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); + } + } + if let Some(scroll) = intent.restore_scroll { + self.git.view.diff.scroll = scroll.min(self.git.view.diff.max_scroll()); + } + } + + fn diff_target_is_current(&self, target: &DiffTarget) -> bool { + match target { + DiffTarget::Status(path) => { + self.git.view.mode == super::ViewMode::Status + && self.selected_filtered_status_path().as_deref() == Some(path) + } + DiffTarget::Commit(oid) => { + self.git.view.mode == super::ViewMode::Log + && !self.git.view.log.drill_down + && self + .git + .view + .log + .commits + .get(self.git.view.log.selected) + .is_some_and(|commit| commit.oid == *oid) + } + DiffTarget::CommitFile { oid, path } => { + self.git.view.mode == super::ViewMode::Log + && self.git.view.log.drill_down + && self + .git + .view + .log + .commits + .get(self.git.view.log.selected) + .is_some_and(|commit| commit.oid == *oid) + && self + .git + .view + .log + .commit_files + .get(self.git.view.log.file_selected) + .is_some_and(|file| file.path == *path) + } + } + } + + fn apply_file_reply(&mut self, reply: GitLoadReply) { + let Some(intent) = self.git.load_controller.file.as_ref() else { + return; + }; + if intent.generation != reply.request.generation || intent.repo != reply.request.repo { + return; + } + let intent = self.git.load_controller.file.take().unwrap(); + if self.current_file_view_key().as_ref() != Some(&intent.key) { + return; + } + let mut file_view = FileViewState { + key: Some(intent.key), + anchor_line: intent.anchor, + ..Default::default() + }; + match reply.result { + Ok(GitLoadPayload::File(content)) => { + file_view.set_content(content); + let initial = intent + .anchor + .map(|line| line.saturating_sub(1).saturating_sub(2)) + .unwrap_or(0); + file_view.scroll = initial.min(file_view.max_scroll()); + } + Ok(_) => return, + Err(error) => file_view.error = Some(error), + } + self.git.view.diff.file_view = file_view; + } + + fn apply_commit_files_reply(&mut self, reply: GitLoadReply) { + let Some(intent) = self.git.load_controller.commit_files.as_ref() else { + return; + }; + if intent.generation != reply.request.generation || intent.repo != reply.request.repo { + return; + } + let intent = self.git.load_controller.commit_files.take().unwrap(); + if self.git.view.mode != ViewMode::Log + || self.git.view.log.drill_down + || self + .git + .view + .log + .commits + .get(self.git.view.log.selected) + .is_none_or(|commit| commit.oid != intent.oid) + { + return; + } + match reply.result { + Ok(GitLoadPayload::CommitFiles(files)) => { + self.git.view.log.set_commit_files(files); + self.git.view.log.file_selected = 0; + self.git.view.log.drill_down = true; + if self.git.view.log.commit_files.is_empty() { + self.clear_diff_state(); + self.git.view.log.diff_title = intent.title; + } else { + self.load_file_diff_for_log_file_selected(); + } + } + Ok(_) => {} + Err(error) => tracing::warn!(error = %error, "failed to load commit files"), + } + } + + fn apply_decorations_reply(&mut self, reply: GitLoadReply) { + let Some(intent) = self.git.load_controller.decorations.as_ref() else { + return; + }; + if intent.generation != reply.request.generation || intent.repo != reply.request.repo { + return; + } + let intent = self.git.load_controller.decorations.take().unwrap(); + match reply.result { + Ok(GitLoadPayload::Decorations(decorations)) => { + self.git.log_decorations = decorations; + self.git.last_refs_fingerprint = Some(intent.fingerprint); + } + Ok(_) => {} + Err(error) => tracing::warn!(error = %error, "failed to load ref decorations"), + } + } + + #[cfg(test)] + pub(crate) fn flush_git_loads_for_test(&mut self, timeout: std::time::Duration) { + let started = std::time::Instant::now(); + while self.git.load_controller.diff.is_some() + || self.git.load_controller.file.is_some() + || self.git.load_controller.commit_files.is_some() + || self.git.load_controller.decorations.is_some() + { + assert!( + started.elapsed() <= timeout, + "git load did not finish in {timeout:?}" + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + let _ = self.poll_git_loads(); + } + } +} diff --git a/src/app/load_controller.rs b/src/app/load_controller.rs new file mode 100644 index 00000000..09d12175 --- /dev/null +++ b/src/app/load_controller.rs @@ -0,0 +1,190 @@ +use crate::git::diff::{GitLoadOperation, GitLoadRequest, GitLoadWorker, LoadLane}; +use crate::ui::file_view::FileViewKey; + +#[derive(Clone, PartialEq, Eq)] +pub(crate) enum DiffTarget { + Status(String), + Commit(git2::Oid), + CommitFile { oid: git2::Oid, path: String }, +} + +impl DiffTarget { + fn operation(&self) -> GitLoadOperation { + match self { + Self::Status(path) => GitLoadOperation::StatusDiff(path.clone()), + Self::Commit(oid) => GitLoadOperation::CommitDiff(*oid), + Self::CommitFile { oid, path } => GitLoadOperation::CommitFileDiff { + oid: *oid, + path: path.clone(), + }, + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) enum DiffLoadMode { + Reset, + KeepScroll(usize), + ResetWithTitle(String), +} + +#[derive(Clone)] +pub(crate) struct DiffIntent { + pub(crate) generation: u64, + pub(crate) repo: String, + pub(crate) target: DiffTarget, + pub(crate) mode: DiffLoadMode, + pub(crate) restore_scroll: Option, +} + +#[derive(Clone)] +pub(crate) struct FileIntent { + pub(crate) generation: u64, + pub(crate) repo: String, + pub(crate) key: FileViewKey, + pub(crate) anchor: Option, +} + +#[derive(Clone)] +pub(crate) struct CommitFilesIntent { + pub(crate) generation: u64, + pub(crate) repo: String, + pub(crate) oid: git2::Oid, + pub(crate) title: String, +} + +#[derive(Clone)] +pub(crate) struct DecorationsIntent { + pub(crate) generation: u64, + pub(crate) repo: String, + pub(crate) fingerprint: u64, +} + +pub(crate) struct LoadController { + pub(crate) worker: GitLoadWorker, + next_generation: u64, + pub(crate) diff: Option, + pub(crate) file: Option, + pub(crate) commit_files: Option, + pub(crate) decorations: Option, +} + +impl LoadController { + pub(crate) fn new() -> Self { + Self { + worker: GitLoadWorker::spawn(), + next_generation: 0, + diff: None, + file: None, + commit_files: None, + decorations: None, + } + } + + fn generation(&mut self) -> u64 { + self.next_generation = self.next_generation.wrapping_add(1); + self.next_generation + } + + pub(crate) fn request_diff(&mut self, repo: &str, target: DiffTarget, mode: DiffLoadMode) { + let restore_scroll = self + .diff + .as_ref() + .filter(|intent| intent.repo == repo && intent.target == target) + .and_then(|intent| intent.restore_scroll); + let generation = self.generation(); + let operation = target.operation(); + self.diff = Some(DiffIntent { + generation, + repo: repo.to_string(), + target, + mode, + restore_scroll, + }); + self.worker.submit(GitLoadRequest { + repo: repo.to_string(), + generation, + operation, + }); + } + + pub(crate) fn request_file(&mut self, repo: &str, key: FileViewKey, anchor: Option) { + let generation = self.generation(); + let operation = match &key { + FileViewKey::Status(path) => GitLoadOperation::WorkdirFile(path.clone()), + FileViewKey::Commit { oid, path, status } => GitLoadOperation::CommitFile { + oid: *oid, + path: path.clone(), + status: *status, + }, + }; + self.file = Some(FileIntent { + generation, + repo: repo.to_string(), + key, + anchor, + }); + self.worker.submit(GitLoadRequest { + repo: repo.to_string(), + generation, + operation, + }); + } + + pub(crate) fn request_commit_files(&mut self, repo: &str, oid: git2::Oid, title: String) { + let generation = self.generation(); + self.commit_files = Some(CommitFilesIntent { + generation, + repo: repo.to_string(), + oid, + title, + }); + self.worker.submit(GitLoadRequest { + repo: repo.to_string(), + generation, + operation: GitLoadOperation::CommitFiles(oid), + }); + } + + pub(crate) fn request_decorations(&mut self, repo: &str, fingerprint: u64) { + let generation = self.generation(); + self.decorations = Some(DecorationsIntent { + generation, + repo: repo.to_string(), + fingerprint, + }); + self.worker.submit(GitLoadRequest { + repo: repo.to_string(), + generation, + operation: GitLoadOperation::Decorations, + }); + } + + pub(crate) fn file_generation(&self) -> Option { + self.file.as_ref().map(|intent| intent.generation) + } + + pub(crate) fn restore_diff_scroll(&mut self, scroll: usize) { + if let Some(intent) = self.diff.as_mut() { + intent.restore_scroll = Some(scroll); + } + } + + pub(crate) fn cancel_diff(&mut self) { + let generation = self.generation(); + self.diff = None; + self.worker.cancel(LoadLane::Diff, generation); + } + + pub(crate) fn cancel_file(&mut self) { + let generation = self.generation(); + self.file = None; + self.worker.cancel(LoadLane::File, generation); + } + + pub(crate) fn cancel_commit_files(&mut self) { + let generation = self.generation(); + self.commit_files = None; + self.worker.cancel(LoadLane::CommitFiles, generation); + } +} diff --git a/src/app/log_nav.rs b/src/app/log_nav.rs index 44aee7c5..2969c1f0 100644 --- a/src/app/log_nav.rs +++ b/src/app/log_nav.rs @@ -1,13 +1,12 @@ use super::{App, LIST_PAGE_SIZE, ViewMode}; -use crate::git::diff::load_commit_files; impl App { pub fn log_commit_filtered_indices(&self) -> &[usize] { - &self.log_view.commits_filter_cache + &self.git.view.log.commits_filter_cache } pub fn log_file_filtered_indices(&self) -> &[usize] { - &self.log_view.commit_files_filter_cache + &self.git.view.log.commit_files_filter_cache } // Returns whether selection changed so the caller can decide whether to @@ -18,17 +17,17 @@ impl App { if indices.is_empty() { return false; } - if indices.contains(&self.log_view.selected) { - self.log_view.selected + if indices.contains(&self.git.view.log.selected) { + self.git.view.log.selected } else { indices[0] } }; - if target == self.log_view.selected { + if target == self.git.view.log.selected { false } else { - self.log_view.selected = target; - self.log_view.commit_scroll_x = 0; + self.git.view.log.selected = target; + self.git.view.log.commit_scroll_x = 0; true } } @@ -39,17 +38,17 @@ impl App { if indices.is_empty() { return false; } - if indices.contains(&self.log_view.file_selected) { - self.log_view.file_selected + if indices.contains(&self.git.view.log.file_selected) { + self.git.view.log.file_selected } else { indices[0] } }; - if target == self.log_view.file_selected { + if target == self.git.view.log.file_selected { false } else { - self.log_view.file_selected = target; - self.log_view.file_scroll_x = 0; + self.git.view.log.file_selected = target; + self.git.view.log.file_scroll_x = 0; true } } @@ -58,7 +57,7 @@ impl App { let selection_changed = self.sync_log_commit_selection_to_filter(); if self.log_commit_filtered_indices().is_empty() { self.clear_diff_state(); - } else if selection_changed || self.diff.hunks.is_empty() { + } else if selection_changed || self.git.view.diff.hunks().is_empty() { self.load_commit_diff_for_selected(); } } @@ -67,25 +66,25 @@ impl App { let selection_changed = self.sync_log_file_selection_to_filter(); if self.log_file_filtered_indices().is_empty() { self.clear_diff_state(); - } else if selection_changed || self.diff.hunks.is_empty() { + } else if selection_changed || self.git.view.diff.hunks().is_empty() { self.load_file_diff_for_log_file_selected(); } } pub fn start_log_search(&mut self) { - if self.log_view.drill_down { - self.log_view.start_file_search(); + if self.git.view.log.drill_down { + self.git.view.log.start_file_search(); } else { - self.log_view.start_commit_search(); + self.git.view.log.start_commit_search(); } } pub fn cancel_log_search(&mut self) { - if self.log_view.drill_down { - self.log_view.cancel_file_search(); + if self.git.view.log.drill_down { + self.git.view.log.cancel_file_search(); self.refresh_file_diff_after_filter_change(); } else { - self.log_view.cancel_commit_search(); + self.git.view.log.cancel_commit_search(); self.refresh_commit_diff_after_filter_change(); // Search ended → prefetch may have been pending; resume if the // selection now sits near the loaded tail. @@ -94,12 +93,12 @@ impl App { } pub fn confirm_log_search(&mut self) { - if self.log_view.drill_down { - if self.log_view.confirm_file_search() { + if self.git.view.log.drill_down { + if self.git.view.log.confirm_file_search() { self.refresh_file_diff_after_filter_change(); } } else { - if self.log_view.confirm_commit_search() { + if self.git.view.log.confirm_commit_search() { self.refresh_commit_diff_after_filter_change(); } // Resume prefetch regardless of whether the query was empty: @@ -110,21 +109,21 @@ impl App { } pub fn log_search_push(&mut self, ch: char) { - if self.log_view.drill_down { - self.log_view.file_search_push(ch); + if self.git.view.log.drill_down { + self.git.view.log.file_search_push(ch); self.refresh_file_diff_after_filter_change(); } else { - self.log_view.commit_search_push(ch); + self.git.view.log.commit_search_push(ch); self.refresh_commit_diff_after_filter_change(); } } pub fn log_search_pop(&mut self) { - if self.log_view.drill_down { - self.log_view.file_search_pop(); + if self.git.view.log.drill_down { + self.git.view.log.file_search_pop(); self.refresh_file_diff_after_filter_change(); } else { - self.log_view.commit_search_pop(); + self.git.view.log.commit_search_pop(); self.refresh_commit_diff_after_filter_change(); } } @@ -135,10 +134,10 @@ impl App { commit_nav: fn(&mut Self), file_nav: fn(&mut Self), ) -> bool { - if self.mode != ViewMode::Log { + if self.git.view.mode != ViewMode::Log { return false; } - if self.log_view.drill_down { + if self.git.view.log.drill_down { file_nav(self); } else { commit_nav(self); @@ -147,30 +146,18 @@ impl App { } pub fn log_drill_in(&mut self) { - let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { + let (oid, title) = match self.git.view.log.commits.get(self.git.view.log.selected) { Some(entry) => (entry.oid, entry.to_string()), None => return, }; - match self.with_repo(|repo| load_commit_files(repo, oid)) { - Ok(files) => { - self.log_view.set_commit_files(files); - self.log_view.file_selected = 0; - self.log_view.drill_down = true; - if self.log_view.commit_files.is_empty() { - self.clear_diff_state(); - self.log_view.diff_title = title; - } else { - self.load_file_diff_for_log_file_selected(); - } - } - Err(e) => { - tracing::warn!(error = %e, "failed to load commit files"); - } - } + self.git + .load_controller + .request_commit_files(&self.git.repo_path, oid, title); } pub fn log_drill_out(&mut self) { - self.log_view.reset_drill_down(); + self.git.load_controller.cancel_commit_files(); + self.git.view.log.reset_drill_down(); self.load_commit_diff_for_selected(); } @@ -200,14 +187,14 @@ impl App { pub fn log_select_up(&mut self) { if self.move_log_commit_in_filter(-1) { - self.log_view.commit_scroll_x = 0; + self.git.view.log.commit_scroll_x = 0; self.load_commit_diff_for_selected(); } } pub fn log_select_down(&mut self) { if self.move_log_commit_in_filter(1) { - self.log_view.commit_scroll_x = 0; + self.git.view.log.commit_scroll_x = 0; self.load_commit_diff_for_selected(); } self.maybe_prefetch_commit_log(); @@ -215,14 +202,14 @@ impl App { pub fn log_page_up(&mut self) { if self.move_log_commit_in_filter(-(LIST_PAGE_SIZE as isize)) { - self.log_view.commit_scroll_x = 0; + self.git.view.log.commit_scroll_x = 0; self.load_commit_diff_for_selected(); } } pub fn log_page_down(&mut self) { if self.move_log_commit_in_filter(LIST_PAGE_SIZE as isize) { - self.log_view.commit_scroll_x = 0; + self.git.view.log.commit_scroll_x = 0; self.load_commit_diff_for_selected(); } self.maybe_prefetch_commit_log(); @@ -236,7 +223,9 @@ impl App { if indices.is_empty() { return false; } - let pos = indices.iter().position(|&i| i == self.log_view.selected); + let pos = indices + .iter() + .position(|&i| i == self.git.view.log.selected); let new_pos = match pos { Some(p) => { let last = indices.len() as isize - 1; @@ -246,10 +235,10 @@ impl App { }; indices[new_pos] }; - if resolved == self.log_view.selected { + if resolved == self.git.view.log.selected { false } else { - self.log_view.selected = resolved; + self.git.view.log.selected = resolved; true } } @@ -262,7 +251,7 @@ impl App { } let pos = indices .iter() - .position(|&i| i == self.log_view.file_selected); + .position(|&i| i == self.git.view.log.file_selected); let new_pos = match pos { Some(p) => { let last = indices.len() as isize - 1; @@ -272,11 +261,11 @@ impl App { }; indices[new_pos] }; - if resolved == self.log_view.file_selected { + if resolved == self.git.view.log.file_selected { false } else { - self.log_view.file_selected = resolved; - self.log_view.file_scroll_x = 0; + self.git.view.log.file_selected = resolved; + self.git.view.log.file_scroll_x = 0; true } } diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 8fc61254..d60001ce 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -2,58 +2,64 @@ use super::{App, ChangedFile, DIFF_PAGE_SIZE, DiffPaneView, Focus, LIST_PAGE_SIZ impl App { pub(crate) fn restore_selection(&mut self, previous_path: Option<&str>) -> Option { - if self.status_view.files.is_empty() { - self.status_view.selected = 0; + if self.git.view.status.files.is_empty() { + self.git.view.status.selected = 0; return None; } if let Some(path) = previous_path && let Some(index) = self - .status_view + .git + .view + .status .files .iter() .position(|file| file.path == path) { - self.status_view.selected = index; + self.git.view.status.selected = index; return Some(path.to_string()); } - self.status_view.selected = self - .status_view + self.git.view.status.selected = self + .git + .view + .status .selected - .min(self.status_view.files.len().saturating_sub(1)); - self.status_view + .min(self.git.view.status.files.len().saturating_sub(1)); + self.git + .view + .status .files - .get(self.status_view.selected) + .get(self.git.view.status.selected) .map(|file| file.path.clone()) } pub fn filtered_indices(&self) -> &[usize] { - &self.status_view.filter_cache + &self.git.view.status.filter_cache } pub fn start_search(&mut self) { - self.status_view.start_search(); + self.git.view.status.start_search(); } pub fn cancel_search(&mut self) { - self.status_view.cancel_search(); + self.git.view.status.cancel_search(); self.refresh_status_diff_after_filter_change(); } pub fn confirm_search(&mut self) { - if self.status_view.confirm_search() { + if self.git.view.status.confirm_search() { self.refresh_status_diff_after_filter_change(); } } pub fn search_push(&mut self, ch: char) { - self.status_view.search_push(ch); + self.git.view.status.search_push(ch); self.refresh_status_diff_after_filter_change(); } pub fn search_pop(&mut self) { - self.status_view.search_pop(); + self.git.view.status.search_pop(); self.refresh_status_diff_after_filter_change(); } @@ -67,12 +73,16 @@ impl App { pub fn selected_filtered_status_file(&self) -> Option<&ChangedFile> { if self .filtered_indices() - .binary_search(&self.status_view.selected) + .binary_search(&self.git.view.status.selected) .is_err() { return None; } - self.status_view.files.get(self.status_view.selected) + self.git + .view + .status + .files + .get(self.git.view.status.selected) } pub(crate) fn sync_selection_to_filter(&mut self) -> bool { @@ -81,20 +91,20 @@ impl App { if indices.is_empty() { return false; } - if indices.contains(&self.status_view.selected) { - self.status_view.selected + if indices.contains(&self.git.view.status.selected) { + self.git.view.status.selected } else { indices[0] } }; - if target == self.status_view.selected { + if target == self.git.view.status.selected { false } else { - self.status_view.selected = target; + self.git.view.status.selected = target; // Match `move_selected_in_filter`: drop the previous file's // horizontal scroll so the newly-shown path starts from column 0. - self.status_view.file_scroll_x = 0; + self.git.view.status.file_scroll_x = 0; true } } @@ -103,7 +113,7 @@ impl App { let selection_changed = self.sync_selection_to_filter(); if self.selected_filtered_status_path().is_none() { self.clear_diff_state(); - } else if selection_changed || self.diff.hunks.is_empty() { + } else if selection_changed || self.git.view.diff.hunks().is_empty() { self.reload_diff(); } } @@ -116,7 +126,9 @@ impl App { if indices.is_empty() { None } else { - let pos = indices.iter().position(|&i| i == self.status_view.selected); + let pos = indices + .iter() + .position(|&i| i == self.git.view.status.selected); let new_pos = match pos { Some(p) => { let last = indices.len() as isize - 1; @@ -128,14 +140,14 @@ impl App { } }; if let Some((pos, new_pos, new_selected)) = resolved - && (Some(new_pos) != pos || self.status_view.selected != new_selected) + && (Some(new_pos) != pos || self.git.view.status.selected != new_selected) { // Mark only after confirming the selection actually changed so // bumping against either end doesn't reset the auto-follow // steered-path memory. self.mark_user_navigated(); - self.status_view.selected = new_selected; - self.status_view.file_scroll_x = 0; + self.git.view.status.selected = new_selected; + self.git.view.status.file_scroll_x = 0; self.reload_diff(); } } @@ -145,7 +157,7 @@ impl App { pub fn select_up(&mut self) { match self.focus { Focus::FileList => { - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { self.tree_select_up(); return; } @@ -155,10 +167,10 @@ impl App { self.move_selected_in_filter(-1); } Focus::DiffViewer => { - if self.diff.view == DiffPaneView::File { - self.diff.file_view.scroll_up(1); + if self.git.view.diff.view == DiffPaneView::File { + self.git.view.diff.file_view.scroll_up(1); } else { - self.diff.scroll = self.diff.scroll.saturating_sub(1); + self.git.view.diff.scroll = self.git.view.diff.scroll.saturating_sub(1); } } Focus::Terminal => {} @@ -168,7 +180,7 @@ impl App { pub fn select_down(&mut self) { match self.focus { Focus::FileList => { - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { self.tree_select_down(); return; } @@ -178,14 +190,16 @@ impl App { self.move_selected_in_filter(1); } Focus::DiffViewer => { - if self.diff.view == DiffPaneView::File { - self.diff.file_view.scroll_down(1); + if self.git.view.diff.view == DiffPaneView::File { + self.git.view.diff.file_view.scroll_down(1); } else { - self.diff.scroll = self + self.git.view.diff.scroll = self + .git + .view .diff .scroll .saturating_add(1) - .min(self.diff.max_scroll()); + .min(self.git.view.diff.max_scroll()); } } Focus::Terminal => {} @@ -195,7 +209,7 @@ impl App { pub fn page_up(&mut self) { match self.focus { Focus::FileList => { - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { self.tree_page_up(); return; } @@ -205,10 +219,11 @@ impl App { self.move_selected_in_filter(-(LIST_PAGE_SIZE as isize)); } Focus::DiffViewer => { - if self.diff.view == DiffPaneView::File { - self.diff.file_view.scroll_up(DIFF_PAGE_SIZE); + if self.git.view.diff.view == DiffPaneView::File { + self.git.view.diff.file_view.scroll_up(DIFF_PAGE_SIZE); } else { - self.diff.scroll = self.diff.scroll.saturating_sub(DIFF_PAGE_SIZE); + self.git.view.diff.scroll = + self.git.view.diff.scroll.saturating_sub(DIFF_PAGE_SIZE); } } Focus::Terminal => {} @@ -218,7 +233,7 @@ impl App { pub fn page_down(&mut self) { match self.focus { Focus::FileList => { - if self.mode == ViewMode::Tree { + if self.git.view.mode == ViewMode::Tree { self.tree_page_down(); return; } @@ -228,14 +243,16 @@ impl App { self.move_selected_in_filter(LIST_PAGE_SIZE as isize); } Focus::DiffViewer => { - if self.diff.view == DiffPaneView::File { - self.diff.file_view.scroll_down(DIFF_PAGE_SIZE); + if self.git.view.diff.view == DiffPaneView::File { + self.git.view.diff.file_view.scroll_down(DIFF_PAGE_SIZE); } else { - self.diff.scroll = self + self.git.view.diff.scroll = self + .git + .view .diff .scroll .saturating_add(DIFF_PAGE_SIZE) - .min(self.diff.max_scroll()); + .min(self.git.view.diff.max_scroll()); } } Focus::Terminal => {} diff --git a/src/app/repository_view.rs b/src/app/repository_view.rs new file mode 100644 index 00000000..93b4feae --- /dev/null +++ b/src/app/repository_view.rs @@ -0,0 +1,100 @@ +use std::collections::BTreeSet; +use std::time::{Instant, SystemTime}; + +use crate::runtime::tree_watch::TreeWatcher; +use crate::ui::diff_pane::DiffPane; +use crate::ui::log_view::LogView; +use crate::ui::status_view::StatusView; +use crate::ui::tree_view::TreeView; + +use super::ViewMode; + +#[derive(Default)] +pub struct AutoFollow { + pub last_manual_nav_at: Option, + pub followed_path: Option, +} + +pub struct RepositoryView { + pub(crate) mode: ViewMode, + pub(crate) status: StatusView, + pub(crate) log: LogView, + pub(crate) tree: TreeView, + pub(crate) diff: DiffPane, + pub(crate) auto_follow: AutoFollow, + pub(crate) selected_snapshot_mtime: Option<(String, Option)>, + pub(crate) tree_watch: TreeWatcher, + pub(crate) tree_dirty: BTreeSet, + pub(crate) tree_dirty_all: bool, + pub(crate) pending_selection: Option<(String, usize)>, +} + +impl Default for RepositoryView { + fn default() -> Self { + Self { + mode: ViewMode::Status, + status: StatusView::default(), + log: LogView::default(), + tree: TreeView::default(), + diff: DiffPane::default(), + auto_follow: AutoFollow::default(), + selected_snapshot_mtime: None, + tree_watch: TreeWatcher::disabled(), + tree_dirty: BTreeSet::new(), + tree_dirty_all: false, + pending_selection: None, + } + } +} + +impl RepositoryView { + pub fn mode(&self) -> ViewMode { + self.mode + } + + pub fn set_mode(&mut self, mode: ViewMode) { + self.mode = mode; + } + + pub fn status(&self) -> &StatusView { + &self.status + } + + pub fn status_mut(&mut self) -> &mut StatusView { + &mut self.status + } + + pub fn log(&self) -> &LogView { + &self.log + } + + pub fn tree(&self) -> &TreeView { + &self.tree + } + + pub fn diff(&self) -> &DiffPane { + &self.diff + } + + pub fn diff_mut(&mut self) -> &mut DiffPane { + &mut self.diff + } + + pub fn set_pending_selection(&mut self, selection: Option<(String, usize)>) { + self.pending_selection = selection; + } + + pub fn pending_selection(&self) -> Option<&(String, usize)> { + self.pending_selection.as_ref() + } + + pub fn take_pending_selection(&mut self) -> Option<(String, usize)> { + self.pending_selection.take() + } + + #[cfg(test)] + pub(crate) fn with_tree_watcher(mut self, tree_watch: TreeWatcher) -> Self { + self.tree_watch = tree_watch; + self + } +} diff --git a/src/app/scroll.rs b/src/app/scroll.rs index cde886f2..1d98ed29 100644 --- a/src/app/scroll.rs +++ b/src/app/scroll.rs @@ -14,11 +14,11 @@ impl App { } pub(crate) fn upper_scroll_x_mut(&mut self) -> &mut usize { - match self.mode { - ViewMode::Status => &mut self.status_view.file_scroll_x, - ViewMode::Tree => &mut self.tree_view.scroll_x, - ViewMode::Log if self.log_view.drill_down => &mut self.log_view.file_scroll_x, - ViewMode::Log => &mut self.log_view.commit_scroll_x, + match self.git.view.mode { + ViewMode::Status => &mut self.git.view.status.file_scroll_x, + ViewMode::Tree => &mut self.git.view.tree.scroll_x, + ViewMode::Log if self.git.view.log.drill_down => &mut self.git.view.log.file_scroll_x, + ViewMode::Log => &mut self.git.view.log.commit_scroll_x, } } @@ -41,19 +41,19 @@ impl App { cache.set(Some((len, max))); max } - match self.mode { + match self.git.view.mode { ViewMode::Status => cached_max( - &self.status_view.path_width_cache, - &self.status_view.files, + &self.git.view.status.path_width_cache, + &self.git.view.status.files, |f| f.display_path().chars().count(), ), // Tree rows are derived (not a stored slice), so cache by // visible-row count directly. Width = indent (depth*2) + 2-char // dir/file marker + name char count. ViewMode::Tree => { - let rows = self.tree_view.visible_rows(); + let rows = self.git.view.tree.visible_rows(); let len = rows.len(); - if let Some((cached_len, cached_max)) = self.tree_view.row_width_cache.get() + if let Some((cached_len, cached_max)) = self.git.view.tree.row_width_cache.get() && cached_len == len { cached_max @@ -63,18 +63,18 @@ impl App { .map(|r| r.depth * 2 + 2 + r.name.chars().count()) .max() .unwrap_or(0); - self.tree_view.row_width_cache.set(Some((len, max))); + self.git.view.tree.row_width_cache.set(Some((len, max))); max } } - ViewMode::Log if self.log_view.drill_down => cached_max( - &self.log_view.commit_files_width_cache, - &self.log_view.commit_files, + ViewMode::Log if self.git.view.log.drill_down => cached_max( + &self.git.view.log.commit_files_width_cache, + &self.git.view.log.commit_files, |f| f.display_path().chars().count(), ), ViewMode::Log => cached_max( - &self.log_view.commit_width_cache, - &self.log_view.commits, + &self.git.view.log.commit_width_cache, + &self.git.view.log.commits, |c| c.summary.chars().count(), ), } diff --git a/src/app/session_io.rs b/src/app/session_io.rs index 34688cfd..31ac443c 100644 --- a/src/app/session_io.rs +++ b/src/app/session_io.rs @@ -9,7 +9,7 @@ impl App { // selected" over the one the user actually left open. pub fn session_to_save(&self) -> SessionState { let mut state = self.save_session(); - if let Some((path, scroll)) = self.pending_selection.as_ref() { + if let Some((path, scroll)) = self.git.view.pending_selection() { state.selected_file = Some(path.clone()); state.scroll = *scroll; } @@ -20,35 +20,33 @@ impl App { SessionState { focus: Some(self.focus), selected_file: self - .status_view + .git + .view + .status .files - .get(self.status_view.selected) + .get(self.git.view.status.selected) .map(|f| f.path.clone()), - scroll: self.diff.scroll, + scroll: self.git.view.diff.scroll, active_pane: self.terminal.active, terminal_fullscreen: self.terminal.fullscreen.fills_body(), - diff_fullscreen: self.diff.fullscreen, + diff_fullscreen: self.git.view.diff.fullscreen, list_fullscreen: self.list_fullscreen, - mode: Some(self.mode), - log_selected: self.log_view.selected, - log_drill_down: self.log_view.drill_down, - log_file_selected: self.log_view.file_selected, - tree_selected_path: self.tree_view.selected_path(), - tree_expanded: self.tree_view.expanded.iter().cloned().collect(), + mode: Some(self.git.view.mode), + log_selected: self.git.view.log.selected, + log_drill_down: self.git.view.log.drill_down, + log_file_selected: self.git.view.log.file_selected, + tree_selected_path: self.git.view.tree.selected_path(), + tree_expanded: self.git.view.tree.expanded.iter().cloned().collect(), } } - // Runs synchronously at startup (before the first snapshot) to stop the - // fresh-launch terminal focus from briefly drawing — and routing keystrokes - // — over a saved `FileList`/`DiffViewer` focus. Idempotent: `restore_session` - // re-applies it once the snapshot arrives, a no-op against the same state. + // Runs before the first snapshot so a fresh launch's terminal focus never + // briefly draws — or routes keystrokes — over a restored list/diff focus. + // Idempotent: `restore_session` re-applies it once the snapshot arrives. pub(crate) fn restore_pane_focus(&mut self, state: &SessionState) { - // Everything below that points *at a pane* — which one was active, the - // fullscreen panel, terminal focus — has nothing to point at until the - // session reports its panes, and this runs before that. Held so it can - // be applied for real when they arrive rather than quietly downgraded - // against an empty list; the rest (mode fullscreens, a focus elsewhere) - // takes effect now. + // Pane-pointing state (active pane, terminal fullscreen, terminal + // focus) means nothing until the session reports its panes, so hold it + // in `pending_terminal` rather than quietly downgrading it against an empty list. self.pending_terminal = self.terminal.panes.is_empty().then(|| state.clone()); self.terminal.active = state .active_pane @@ -73,24 +71,22 @@ impl App { if self.terminal.fullscreen.fills_body() { self.focus = Focus::Terminal; } - self.diff.fullscreen = state.diff_fullscreen && !self.terminal.fullscreen.fills_body(); - if self.diff.fullscreen { + self.git.view.diff.fullscreen = + state.diff_fullscreen && !self.terminal.fullscreen.fills_body(); + if self.git.view.diff.fullscreen { self.focus = Focus::DiffViewer; } self.list_fullscreen = state.list_fullscreen && !self.terminal.fullscreen.fills_body() - && !self.diff.fullscreen; + && !self.git.view.diff.fullscreen; if self.list_fullscreen { self.focus = Focus::FileList; } } - // Runs as soon as the session is loaded, not on the first snapshot. Almost - // none of it needs to wait: panes/focus/fullscreen need no data, and Log - // and Tree read what they need directly. Status mode's selection is the - // one exception, held in `pending_selection` until the changed files arrive - // — that deferral can't collide with user input: there's no way to pick a - // file out of a list that's still empty. + // Runs as soon as the session loads, not on the first snapshot: only + // Status's selection needs snapshot data, so it waits in + // `pending_selection` — an empty list can't collide with user input. pub fn restore_session(&mut self, state: &SessionState) { self.restore_pane_focus(state); @@ -99,9 +95,10 @@ impl App { match state.mode { Some(ViewMode::Log) => self.restore_log_session(state), Some(ViewMode::Tree) => self.restore_tree_session(state), - _ if self.status_view.files.is_empty() => { - self.pending_selection = - state.selected_file.clone().map(|path| (path, state.scroll)); + _ if self.git.view.status.files.is_empty() => { + self.git.view.set_pending_selection( + state.selected_file.clone().map(|path| (path, state.scroll)), + ); } _ => self.restore_status_session(state), } @@ -118,11 +115,17 @@ impl App { fn restore_status_session(&mut self, state: &SessionState) { if let Some(path) = &state.selected_file - && let Some(idx) = self.status_view.files.iter().position(|f| &f.path == path) + && let Some(idx) = self + .git + .view + .status + .files + .iter() + .position(|f| &f.path == path) { - self.status_view.selected = idx; + self.git.view.status.selected = idx; self.refresh_diff(true); - self.diff.scroll = state.scroll.min(self.diff.max_scroll()); + self.git.load_controller.restore_diff_scroll(state.scroll); } // If the saved file is gone, leave selected/scroll as they were after // the initial snapshot — applying saved_scroll to a different file @@ -130,46 +133,43 @@ impl App { } fn restore_tree_session(&mut self, state: &SessionState) { - self.mode = ViewMode::Tree; + self.git.view.set_mode(ViewMode::Tree); // A status search started before this restore (e.g. `/` pressed while // the default Status view awaited the first snapshot) would otherwise // stay active and capture Tree keystrokes. Drop it. - self.status_view.cancel_search(); + self.git.view.status.cancel_search(); self.clear_diff_state(); // Restoring expansion mutates the cache/expanded set; drop the stale // row-width bound so horizontal scroll clamps to the restored rows. - self.tree_view.row_width_cache.set(None); - // The session file is an on-disk boundary: drop any entry that isn't a - // safe repo-internal relative path so a hand-edited `..` or absolute - // path can't drive a directory read outside the working tree. - // `refresh_tree_cache` prunes any that no longer exist on disk, so a - // stale expansion can't surface a "tree error". - self.tree_view.expanded = state + self.git.view.tree.row_width_cache.set(None); + // The session file is an on-disk boundary: keep only safe + // repo-internal relative paths, so a hand-edited `..` or absolute path + // can't drive a directory read outside the working tree. + // (`refresh_tree_cache` prunes entries missing on disk.) + self.git.view.tree.expanded = state .tree_expanded .iter() .filter(|p| crate::ui::tree_view::is_safe_rel_path(p)) .cloned() .collect(); self.refresh_tree_cache(); - // Restore the cursor by path when it still resolves to a visible row. if let Some(path) = &state.tree_selected_path { - let rows = self.tree_view.visible_rows(); + let rows = self.git.view.tree.visible_rows(); if let Some(idx) = rows.iter().position(|r| &r.path == path) { - self.tree_view.selected = idx; + self.git.view.tree.selected = idx; } } - let row_count = self.tree_view.visible_rows().len(); - self.tree_view.clamp_selection(row_count); + let row_count = self.git.view.tree.visible_rows().len(); + self.git.view.tree.clamp_selection(row_count); self.preview_tree_selected(); } fn restore_log_session(&mut self, state: &SessionState) { - // A page worker launched before the restore (e.g. via `toggle_mode` - // earlier in this frame) would race against the fresh `set_commits` - // below: its reply would be matched by `loaded_count` and silently - // appended over the restored list. Cancel before mutating state. + // A page worker launched before the restore would race against the + // fresh `set_commits` below: its reply would be silently appended over + // the restored list. Cancel before mutating state. self.cancel_commit_log_page_fetch(); - let page_size = self.pagination.page_size; + let page_size = self.git.commit_log.page_size(); let commits = match self.with_repo(|repo| load_commit_log(repo, page_size)) { Ok(c) => c, Err(e) => { @@ -178,21 +178,23 @@ impl App { } }; let fully_loaded = commits.len() < page_size; - self.log_view.set_commits(commits); - self.log_view.fully_loaded = fully_loaded; - self.log_view.selected = state + self.git.view.log.set_commits(commits); + self.git.view.log.fully_loaded = fully_loaded; + self.git.view.log.selected = state .log_selected - .min(self.log_view.commits.len().saturating_sub(1)); + .min(self.git.view.log.commits.len().saturating_sub(1)); // Avoid a same-tick HEAD-change-trigger reload on the next snapshot. - self.pagination.last_head_oid = self.log_view.commits.first().map(|c| c.oid); - self.mode = ViewMode::Log; + self.git + .commit_log + .set_last_head_oid(self.git.view.log.commits.first().map(|c| c.oid)); + self.git.view.set_mode(ViewMode::Log); if state.log_drill_down { self.restore_log_drill_down(state); } else { self.load_commit_diff_for_selected(); } - self.diff.scroll = state.scroll.min(self.diff.max_scroll()); + self.git.load_controller.restore_diff_scroll(state.scroll); // Restored cursor may already sit close to the tail of the first page; // kick off the next prefetch so the first key move doesn't bump into a // not-yet-loaded boundary. @@ -200,15 +202,14 @@ impl App { } fn restore_log_drill_down(&mut self, state: &SessionState) { - let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { + let (oid, title) = match self.git.view.log.commits.get(self.git.view.log.selected) { Some(entry) => (entry.oid, entry.to_string()), None => { - // Saved drill-down pointed at a commit that's no longer in the - // loaded first page (history rewrite, force-push) — surface - // this so the user knows why they're back at the commit-level - // view instead of where they left off. + // Saved drill-down pointed at a commit no longer in the loaded + // first page (history rewrite, force-push) — surface why the + // user is back at the commit-level view, not where they left off. tracing::warn!( - selected = self.log_view.selected, + selected = self.git.view.log.selected, "drill-down restore: saved commit index is out of range" ); self.raise_notice( @@ -221,16 +222,16 @@ impl App { }; match self.with_repo(|repo| load_commit_files(repo, oid)) { Ok(files) => { - self.log_view.set_commit_files(files); - self.log_view.drill_down = true; - if self.log_view.commit_files.is_empty() { - self.log_view.file_selected = 0; + self.git.view.log.set_commit_files(files); + self.git.view.log.drill_down = true; + if self.git.view.log.commit_files.is_empty() { + self.git.view.log.file_selected = 0; self.clear_diff_state(); - self.log_view.diff_title = title; + self.git.view.log.diff_title = title; } else { - self.log_view.file_selected = state + self.git.view.log.file_selected = state .log_file_selected - .min(self.log_view.commit_files.len().saturating_sub(1)); + .min(self.git.view.log.commit_files.len().saturating_sub(1)); self.load_file_diff_for_log_file_selected(); } } diff --git a/src/app/snapshot_io.rs b/src/app/snapshot_io.rs index 588e1248..b87af493 100644 --- a/src/app/snapshot_io.rs +++ b/src/app/snapshot_io.rs @@ -7,18 +7,21 @@ impl App { // collapses to one. Applying is NOT done here: this half touches no git // state, so every project can run it every tick to keep its unbounded // channel from growing, regardless of which tab is shown. - pub fn drain_snapshot(&mut self) { - while let Ok(msg) = self.snapshot.try_recv() { - self.pending_snapshot = Some(msg); + pub fn drain_snapshot(&mut self) -> bool { + let mut received = false; + while let Ok(msg) = self.git.snapshot.try_recv() { + received = true; + self.git.pending_snapshot = Some(msg); } + received } // Applying runs a full `refresh_diff`, so this is for the on-screen project // only — hidden projects' snapshots wait in `pending_snapshot` and apply on // the first tick after their tab comes forward. - pub fn poll_snapshot(&mut self) { - self.drain_snapshot(); - match self.pending_snapshot.take() { + pub fn poll_snapshot(&mut self) -> bool { + let received = self.drain_snapshot(); + match self.git.pending_snapshot.take() { Some(SnapshotMsg::Ok(snapshot, mtimes)) => { self.ingest_snapshot(snapshot, mtimes); } @@ -29,8 +32,9 @@ impl App { // snapshot should still apply the saved selection. Saving must // not be blocked by it — see `session_to_save`, which merges. } - None => {} + None => return received, } + true } // Split out so tests can drive the merge/auto-follow logic with deterministic @@ -40,31 +44,67 @@ impl App { // path, so the ordinary "keep the cursor on the same file" machinery // performs the restore — no separate restore step to collide with. let previous_path = self - .status_view + .git + .view + .status .files - .get(self.status_view.selected) + .get(self.git.view.status.selected) .map(|f| f.path.clone()) .or_else(|| { - self.pending_selection - .as_ref() + self.git + .view + .pending_selection() .map(|(path, _)| path.clone()) }); + let previous_selected = previous_path.as_ref().and_then(|path| { + self.git + .view + .status + .files + .iter() + .find(|file| &file.path == path) + .cloned() + }); + let previous_snapshot_mtime = self.git.view.selected_snapshot_mtime.clone().or_else(|| { + previous_path.as_ref().map(|path| { + ( + path.clone(), + self.git.view.status.hot_table.get(path).copied(), + ) + }) + }); let new_head = snapshot.head_oid; - self.branch_name = snapshot.branch_name; + self.git.branch_name = snapshot.branch_name; self.refresh_log_decorations(snapshot.refs_fingerprint); - self.status_view.set_files(snapshot.files); - self.status_view.recompute_filter(); - self.tracking = snapshot.tracking; - self.merge_hot_table(mtimes); + self.git.view.status.set_files(snapshot.files); + self.git.view.status_mut().recompute_filter(); + self.git.tracking = snapshot.tracking; + self.merge_hot_table(&mtimes); self.restore_selection(previous_path.as_deref()); self.sync_selection_to_filter(); let auto_followed = self.try_auto_follow(); let selected_path = self.selected_filtered_status_path(); + let selected_snapshot_mtime = selected_path + .as_ref() + .map(|path| (path.clone(), mtimes.get(path).copied())); let selected_path_changed = auto_followed || selected_path != previous_path; - if self.mode == ViewMode::Status { + let selected_state_unchanged = !selected_path_changed + && previous_selected.as_ref().is_some_and(|previous| { + self.selected_filtered_status_file().is_some_and(|current| { + current.path == previous.path + && current.old_path == previous.old_path + && current.index == previous.index + && current.worktree == previous.worktree + && previous_snapshot_mtime == selected_snapshot_mtime + }) + }); + self.git.view.selected_snapshot_mtime = selected_snapshot_mtime; + if self.git.view.mode == ViewMode::Status { if selected_path.is_some() { - self.refresh_diff(selected_path_changed); + if !selected_state_unchanged { + self.refresh_diff(selected_path_changed); + } } else { self.clear_diff_state(); } @@ -73,18 +113,18 @@ impl App { // Skip on the very first snapshot (prior == None) so initial loads // don't double-fetch the commit log on top of `toggle_mode`'s eager load. - let prior_head = self.pagination.last_head_oid; - self.pagination.last_head_oid = new_head; - if prior_head.is_some() && prior_head != new_head && self.mode == ViewMode::Log { + let prior_head = self.git.commit_log.last_head_oid(); + self.git.commit_log.set_last_head_oid(new_head); + if prior_head.is_some() && prior_head != new_head && self.git.view.mode == ViewMode::Log { self.refresh_commit_log_after_head_change(); } // The saved scroll belongs to the saved file, so it only applies if // the cursor actually landed there. - if let Some((path, scroll)) = self.pending_selection.take() + if let Some((path, scroll)) = self.git.view.take_pending_selection() && self.selected_filtered_status_path().as_deref() == Some(path.as_str()) { - self.diff.scroll = scroll.min(self.diff.max_scroll()); + self.git.load_controller.restore_diff_scroll(scroll); } } @@ -92,16 +132,12 @@ impl App { // fingerprint rather than run per poll. A failure leaves the previous map in // place: stale chips beat chips vanishing on a transient read error. fn refresh_log_decorations(&mut self, fingerprint: u64) { - if self.last_refs_fingerprint == Some(fingerprint) { + if self.git.last_refs_fingerprint == Some(fingerprint) { return; } - match self.with_repo(crate::git::diff::load_log_decorations) { - Ok(decorations) => { - self.log_decorations = decorations; - self.last_refs_fingerprint = Some(fingerprint); - } - Err(e) => tracing::warn!(error = %e, "failed to load ref decorations"), - } + self.git + .load_controller + .request_decorations(&self.git.repo_path, fingerprint); } // A path whose previous mtime was newer than the freshly observed one keeps @@ -109,18 +145,17 @@ impl App { // the same path and must not demote a recent edit to cool. Updates in place // instead of rebuilding the HashMap every tick: the steady state has the // same path set tick after tick. - pub(crate) fn merge_hot_table(&mut self, mtimes: HashMap) { - let table = &mut self.status_view.hot_table; + pub(crate) fn merge_hot_table(&mut self, mtimes: &HashMap) { + let table = &mut self.git.view.status.hot_table; table.retain(|path, _| mtimes.contains_key(path)); for (path, new_mtime) in mtimes { - table - .entry(path) - .and_modify(|stored| { - if new_mtime > *stored { - *stored = new_mtime; - } - }) - .or_insert(new_mtime); + if let Some(stored) = table.get_mut(path) { + if new_mtime > stored { + *stored = *new_mtime; + } + } else { + table.insert(path.clone(), *new_mtime); + } } } } diff --git a/src/app/terminal_ctrl.rs b/src/app/terminal_ctrl.rs index 2208fed5..7500df2d 100644 --- a/src/app/terminal_ctrl.rs +++ b/src/app/terminal_ctrl.rs @@ -6,11 +6,13 @@ use super::{App, Focus, NoticeKind}; use crate::runtime::terminal::TerminalFullscreen; impl App { - pub fn poll_terminal(&mut self) { + pub fn poll_terminal(&mut self) -> bool { // `TerminalState::poll` only signals exited panes; re-clamping focus // and fullscreen when the active pane was one of them stays here. - if !self.terminal.poll().is_empty() { + let (exited, mut changed) = self.terminal.poll_with_activity(); + if !exited.is_empty() { self.clamp_active_pane_after_removal(); + changed = true; } // The panes arrived, so the terminal half of the session — which pane // was active, whether the panel was fullscreen, whether the input focus @@ -21,7 +23,9 @@ impl App { && let Some(state) = self.pending_terminal.take() { self.restore_pane_focus(&state); + changed = true; } + changed } pub fn open_new_pane(&mut self) { @@ -34,7 +38,7 @@ impl App { // `create_pane` made the new pane active; move app focus onto it and // drop competing fullscreen so focus/render/hints stay in sync. self.focus = Focus::Terminal; - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.list_fullscreen = false; } @@ -119,7 +123,7 @@ impl App { self.terminal.sync_visible_window(); self.focus = Focus::Terminal; // Drop competing fullscreen so focus/render/hints stay in sync. - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.list_fullscreen = false; } } @@ -127,7 +131,7 @@ impl App { pub fn swap_active_pane_with(&mut self, idx: usize) { if self.terminal.swap_active_with(idx) { self.focus = Focus::Terminal; - self.diff.fullscreen = false; + self.git.view.diff.fullscreen = false; self.list_fullscreen = false; } } diff --git a/src/app/tests/app_repository_integration.rs b/src/app/tests/app_repository_integration.rs new file mode 100644 index 00000000..ece6e86b --- /dev/null +++ b/src/app/tests/app_repository_integration.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn app_facade_adopts_repository_identity_without_replacing_view_state() { + let mut app = app_with_files(vec!["src/lib.rs"]); + app.git.view.status.selected = 0; + + app.adopt_repository_id("opaque-id".to_string()); + + assert_eq!(app.repository_id(), Some("opaque-id")); + assert_eq!(app.repository_path(), "."); + assert_eq!(app.status_view().selected, 0); + assert_eq!(app.status_view().files[0].path, "src/lib.rs"); +} diff --git a/src/app/tests/async_load.rs b/src/app/tests/async_load.rs new file mode 100644 index 00000000..9dac3a29 --- /dev/null +++ b/src/app/tests/async_load.rs @@ -0,0 +1,224 @@ +use super::*; + +fn status_app(path: &str) -> App { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.git.repo_path = path.to_string(); + app +} + +fn repo_with_two_dirty_files() -> (tempfile::TempDir, String) { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a.rs"), "old a\n").unwrap(); + std::fs::write(Path::new(&path).join("b.rs"), "old b\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "base"]); + std::fs::write(Path::new(&path).join("a.rs"), "latest a\n").unwrap(); + std::fs::write(Path::new(&path).join("b.rs"), "latest b\n").unwrap(); + (dir, path) +} + +fn diff_text(app: &App) -> String { + app.git + .view + .diff + .hunks() + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.as_str()) + .collect::>() + .join("\n") +} + +#[test] +fn 십만번_연속_선택은_입력_loop를_block하지_않고_마지막_diff를_적용한다() { + let (_dir, path) = repo_with_two_dirty_files(); + let mut app = status_app(&path); + let started = Instant::now(); + + for _ in 0..50_000 { + app.select_down(); + app.select_up(); + } + + let input_latency = started.elapsed(); + eprintln!("100k selection input loop: {input_latency:?}"); + assert!( + input_latency < Duration::from_secs(5), + "100k selections blocked for {:?}", + input_latency + ); + app.flush_git_loads_for_test(Duration::from_secs(5)); + assert!(diff_text(&app).contains("latest a")); + assert!(!diff_text(&app).contains("latest b")); +} + +#[test] +fn 저장소가_바뀐_뒤_도착한_이전_저장소_결과는_버린다() { + let (_old_dir, old_path) = repo_with_two_dirty_files(); + let (_new_dir, new_path) = repo_with_two_dirty_files(); + std::fs::write(Path::new(&new_path).join("a.rs"), "new repo only\n").unwrap(); + let mut app = status_app(&old_path); + + app.reload_diff(); + app.git.repo_path = new_path; + app.reload_diff(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert!(diff_text(&app).contains("new repo only")); + assert!( + app.notice + .as_ref() + .is_none_or(|notice| notice.kind != NoticeKind::Diff) + ); +} + +#[test] +fn 연속_commit_선택은_마지막_oid의_diff와_title만_적용한다() { + let (_dir, path) = make_repo(); + let file = Path::new(&path).join("a.rs"); + std::fs::write(&file, "one\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "first"]); + std::fs::write(&file, "two\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "second"]); + let mut app = app_with_files(vec![]); + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log + .set_commits(load_commit_log(&open_repo(&path), 10).unwrap()); + + app.git.view.log.selected = 0; + app.load_commit_diff_for_selected(); + app.git.view.log.selected = 1; + app.load_commit_diff_for_selected(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert!(app.git.view.log.diff_title.contains("first")); + assert!(!app.git.view.log.diff_title.contains("second")); + assert!(diff_text(&app).contains("one")); +} + +#[test] +fn 비동기_새로고침은_diff_검색과_scroll을_보존한다() { + let (_dir, path) = make_repo(); + let file = Path::new(&path).join("a.rs"); + std::fs::write(&file, "zero\none\ntwo\nthree\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "base"]); + std::fs::write(&file, "zero\nneedle\ntwo changed\nthree\n").unwrap(); + let mut app = app_with_files(vec!["a.rs"]); + app.git.repo_path = path; + app.git + .view + .diff + .set_hunks(vec![context_hunk(&["old", "old", "old"])]); + app.git.view.diff.scroll = 2; + app.git.view.diff.search.query.set("needle"); + let old_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1); + let new_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(2); + app.git + .view + .status + .hot_table + .insert("a.rs".into(), old_mtime); + + app.ingest_snapshot( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + refs_fingerprint: 0, + }, + HashMap::from([("a.rs".to_string(), new_mtime)]), + ); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert_eq!(app.git.view.diff.search.query.as_str(), "needle"); + assert!(!app.git.view.diff.search.matches.is_empty()); + assert_eq!( + app.git.view.diff.scroll, + 2.min(app.git.view.diff.max_scroll()) + ); +} + +#[test] +fn diff보다_나중에_연_file_view는_diff_reply가_닫지_않는다() { + let (_dir, path) = repo_with_two_dirty_files(); + let mut app = status_app(&path); + + app.reload_diff(); + app.toggle_diff_file_view(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert_eq!(app.git.view.diff.view, DiffPaneView::File); + assert_eq!( + app.git.view.diff.file_view.key, + Some(FileViewKey::Status("a.rs".to_string())) + ); + assert_eq!(app.git.view.diff.file_view.content, "latest a\n"); +} + +#[test] +fn selection_change_does_not_preserve_the_previous_file_view() { + let (_dir, path) = repo_with_two_dirty_files(); + let mut app = status_app(&path); + + app.reload_diff(); + app.toggle_diff_file_view(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + assert_eq!( + app.git.view.diff.file_view.key, + Some(FileViewKey::Status("a.rs".to_string())) + ); + + app.select_down(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert_eq!(app.selected_filtered_status_path().as_deref(), Some("b.rs")); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.file_view.key, None); + assert!(diff_text(&app).contains("latest b")); +} + +#[test] +fn mode_switch_drops_a_stale_commit_files_reply() { + let (_dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a.rs"), "one\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "first"]); + let mut app = app_with_files(vec!["a.rs"]); + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log + .set_commits(load_commit_log(&open_repo(&path), 10).unwrap()); + + app.log_drill_in(); + app.toggle_mode(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert_eq!(app.git.view.mode, ViewMode::Status); + assert!(!app.git.view.log.drill_down); +} + +#[test] +fn 현재_선택의_worker_실패는_diff_notice로_남는다() { + let mut app = app_with_files(vec!["a.rs"]); + app.git.repo_path = "repository-that-does-not-exist".to_string(); + + app.reload_diff(); + app.flush_git_loads_for_test(Duration::from_secs(5)); + + assert_eq!( + app.notice.as_ref().map(|notice| notice.kind), + Some(NoticeKind::Diff) + ); +} diff --git a/src/app/tests/auto_follow.rs b/src/app/tests/auto_follow.rs index f846ff47..0922073c 100644 --- a/src/app/tests/auto_follow.rs +++ b/src/app/tests/auto_follow.rs @@ -25,9 +25,9 @@ fn ingest_snapshot_populates_hot_table_from_mtimes() { app.ingest_snapshot(snap, mtimes); - assert_eq!(app.status_view.hot_table.len(), 2); - assert!(app.status_view.hot_table.contains_key("a.rs")); - assert!(app.status_view.hot_table.contains_key("b.rs")); + assert_eq!(app.git.view.status.hot_table.len(), 2); + assert!(app.git.view.status.hot_table.contains_key("a.rs")); + assert!(app.git.view.status.hot_table.contains_key("b.rs")); } #[test] @@ -39,11 +39,11 @@ fn merge_hot_table_drops_paths_missing_from_new_snapshot() { snapshot_with(&["a.rs"]), HashMap::from([("a.rs".to_string(), now)]), ); - assert!(app.status_view.hot_table.contains_key("a.rs")); + assert!(app.git.view.status.hot_table.contains_key("a.rs")); app.ingest_snapshot(snapshot_with(&["b.rs"]), HashMap::new()); - assert!(!app.status_view.hot_table.contains_key("a.rs")); - assert!(!app.status_view.hot_table.contains_key("b.rs")); + assert!(!app.git.view.status.hot_table.contains_key("a.rs")); + assert!(!app.git.view.status.hot_table.contains_key("b.rs")); } #[test] @@ -64,13 +64,13 @@ fn merge_hot_table_replaces_only_when_newer() { // The earlier mtime must not overwrite the newer observation; a // rename-from-stash scenario can resurrect older mtimes for the // same path and would otherwise demote a fresh edit to cool. - assert_eq!(app.status_view.hot_table.get("a.rs"), Some(&newer)); + assert_eq!(app.git.view.status.hot_table.get("a.rs"), Some(&newer)); } #[test] fn auto_follow_selects_freshest_hot_file_when_idle() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 0; + app.git.view.status.selected = 0; let now = SystemTime::now(); app.ingest_snapshot( @@ -83,15 +83,18 @@ fn auto_follow_selects_freshest_hot_file_when_idle() { // b.rs is fresher and the user is idle (last_manual_nav_at = None), // so selection must move from a.rs to b.rs. - assert_eq!(app.status_view.selected, 1); - assert_eq!(app.auto_follow.followed_path.as_deref(), Some("b.rs")); + assert_eq!(app.git.view.status.selected, 1); + assert_eq!( + app.git.view.auto_follow.followed_path.as_deref(), + Some("b.rs") + ); } #[test] fn auto_follow_skipped_when_user_recently_navigated() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 0; - app.auto_follow.last_manual_nav_at = Some(Instant::now()); + app.git.view.status.selected = 0; + app.git.view.auto_follow.last_manual_nav_at = Some(Instant::now()); let now = SystemTime::now(); app.ingest_snapshot( @@ -99,15 +102,15 @@ fn auto_follow_skipped_when_user_recently_navigated() { HashMap::from([("b.rs".to_string(), now)]), ); - assert_eq!(app.status_view.selected, 0); - assert!(app.auto_follow.followed_path.is_none()); + assert_eq!(app.git.view.status.selected, 0); + assert!(app.git.view.auto_follow.followed_path.is_none()); } #[test] fn auto_follow_skipped_when_focus_not_filelist() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); app.focus = Focus::DiffViewer; - app.status_view.selected = 0; + app.git.view.status.selected = 0; let now = SystemTime::now(); app.ingest_snapshot( @@ -115,15 +118,15 @@ fn auto_follow_skipped_when_focus_not_filelist() { HashMap::from([("b.rs".to_string(), now)]), ); - assert_eq!(app.status_view.selected, 0); - assert!(app.auto_follow.followed_path.is_none()); + assert_eq!(app.git.view.status.selected, 0); + assert!(app.git.view.auto_follow.followed_path.is_none()); } #[test] fn auto_follow_skipped_when_disabled_in_config() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.cfg_agent_indicator.auto_follow = false; - app.status_view.selected = 0; + app.git.agent_indicator.auto_follow = false; + app.git.view.status.selected = 0; let now = SystemTime::now(); app.ingest_snapshot( @@ -131,13 +134,13 @@ fn auto_follow_skipped_when_disabled_in_config() { HashMap::from([("b.rs".to_string(), now)]), ); - assert_eq!(app.status_view.selected, 0); + assert_eq!(app.git.view.status.selected, 0); } #[test] fn auto_follow_skipped_when_freshest_is_already_selected() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 1; + app.git.view.status.selected = 1; let now = SystemTime::now(); app.ingest_snapshot( @@ -147,20 +150,20 @@ fn auto_follow_skipped_when_freshest_is_already_selected() { // Selection already points to b.rs — no need to steer or arm the // "already followed here" guard. - assert_eq!(app.status_view.selected, 1); - assert!(app.auto_follow.followed_path.is_none()); + assert_eq!(app.git.view.status.selected, 1); + assert!(app.git.view.auto_follow.followed_path.is_none()); } #[test] fn select_down_marks_user_active_when_focus_is_filelist() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); app.focus = Focus::FileList; - app.auto_follow.followed_path = Some("a.rs".to_string()); + app.git.view.auto_follow.followed_path = Some("a.rs".to_string()); app.select_down(); - assert!(app.auto_follow.last_manual_nav_at.is_some()); - assert!(app.auto_follow.followed_path.is_none()); + assert!(app.git.view.auto_follow.last_manual_nav_at.is_some()); + assert!(app.git.view.auto_follow.followed_path.is_none()); } #[test] @@ -170,15 +173,15 @@ fn select_down_does_not_mark_when_focus_is_diff() { app.select_down(); - assert!(app.auto_follow.last_manual_nav_at.is_none()); + assert!(app.git.view.auto_follow.last_manual_nav_at.is_none()); } #[test] fn auto_follow_respects_search_filter() { let mut app = app_with_files(vec!["alpha.rs", "beta.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); - app.status_view.selected = 0; // alpha.rs (the only filtered entry) + app.git.view.status.search_query.set("alpha"); + app.git.view.status.recompute_filter(); + app.git.view.status.selected = 0; // alpha.rs (the only filtered entry) let now = SystemTime::now(); app.ingest_snapshot( @@ -191,7 +194,7 @@ fn auto_follow_respects_search_filter() { // beta.rs is fresher but filtered out, so auto-follow must not // jump to a row the user cannot even see. - assert_eq!(app.status_view.selected, 0); + assert_eq!(app.git.view.status.selected, 0); } #[test] @@ -202,7 +205,7 @@ fn auto_follow_excludes_future_mtime_clock_skew() { // beat every other candidate's `mtime > bm` comparison. // Future-stamped files must be excluded from consideration. let mut app = app_with_files(vec!["bogus.rs", "real.rs"]); - app.status_view.selected = 0; + app.git.view.status.selected = 0; let now = SystemTime::now(); app.ingest_snapshot( @@ -216,11 +219,16 @@ fn auto_follow_excludes_future_mtime_clock_skew() { // real.rs (the only candidate with a sane timestamp) must win, // and bogus.rs must not be recorded as the steered path. let real_idx = app - .status_view + .git + .view + .status .files .iter() .position(|f| f.path == "real.rs") .expect("real.rs must be in the file list"); - assert_eq!(app.status_view.selected, real_idx); - assert_eq!(app.auto_follow.followed_path.as_deref(), Some("real.rs")); + assert_eq!(app.git.view.status.selected, real_idx); + assert_eq!( + app.git.view.auto_follow.followed_path.as_deref(), + Some("real.rs") + ); } diff --git a/src/app/tests/commit_log.rs b/src/app/tests/commit_log.rs index 72f373df..221bd6b7 100644 --- a/src/app/tests/commit_log.rs +++ b/src/app/tests/commit_log.rs @@ -12,56 +12,55 @@ fn fake_entry(time: i64) -> CommitEntry { pub(super) fn seed_log_app(entries: usize, page_size: usize, threshold: usize) -> App { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.pagination.page_size = page_size; - app.pagination.prefetch_threshold = threshold; + app.git.view.mode = ViewMode::Log; + app.configure_commit_log(page_size, threshold); let commits: Vec<_> = (0..entries).map(|i| fake_entry(i as i64)).collect(); - app.log_view.set_commits(commits); + app.git.view.log.set_commits(commits); app } #[test] fn maybe_prefetch_no_ops_in_status_mode() { let mut app = seed_log_app(10, 5, 5); - app.mode = ViewMode::Status; - app.log_view.selected = 9; + app.git.view.mode = ViewMode::Status; + app.git.view.log.selected = 9; app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.git.view.log.pending_fetch); + assert!(!app.commit_log_fetch_pending()); } #[test] fn maybe_prefetch_no_ops_when_empty() { let mut app = seed_log_app(0, 5, 5); app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.git.view.log.pending_fetch); + assert!(!app.commit_log_fetch_pending()); } #[test] fn maybe_prefetch_no_ops_when_fully_loaded() { let mut app = seed_log_app(10, 5, 5); - app.log_view.fully_loaded = true; - app.log_view.selected = 9; + app.git.view.log.fully_loaded = true; + app.git.view.log.selected = 9; app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.git.view.log.pending_fetch); + assert!(!app.commit_log_fetch_pending()); } #[test] fn maybe_prefetch_no_ops_when_far_from_tail() { // 10 loaded, threshold 3 — selected at 5 is 5 rows from tail, no prefetch. let mut app = seed_log_app(10, 5, 3); - app.log_view.selected = 5; + app.git.view.log.selected = 5; app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.git.view.log.pending_fetch); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -72,18 +71,17 @@ fn maybe_prefetch_triggers_when_near_tail() { run_git(&path, &["add", "."]); run_git(&path, &["commit", "-m", "c"]); let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; + app.git.repo_path = path.clone(); + app.git.view.log.selected = 6; app.maybe_prefetch_commit_log(); - assert!(app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_some()); + assert!(app.git.view.log.pending_fetch); + assert!(app.commit_log_fetch_pending()); // Wait for the worker to land so its result doesn't leak into a // subsequent test scenario. - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } @@ -94,69 +92,46 @@ fn maybe_prefetch_suppresses_duplicate_pending() { run_git(&path, &["add", "."]); run_git(&path, &["commit", "-m", "c"]); let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; + app.git.repo_path = path.clone(); + app.git.view.log.selected = 6; app.maybe_prefetch_commit_log(); - let first_rx_ptr = app.pagination.page_rx.as_ref().map(|r| r as *const _); - assert!(first_rx_ptr.is_some()); + assert!(app.commit_log_fetch_pending()); app.maybe_prefetch_commit_log(); - let second_rx_ptr = app.pagination.page_rx.as_ref().map(|r| r as *const _); - // The second call must reuse the same receiver — no second spawn. - assert_eq!(first_rx_ptr, second_rx_ptr); + assert!(app.commit_log_fetch_pending()); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } #[test] -fn poll_drains_matching_skip_into_commits() { - let mut app = seed_log_app(3, 5, 1); - app.log_view.pending_fetch = true; - let (tx, rx) = mpsc::channel(); - app.pagination.page_rx = Some(rx); - // Worker thinks the loaded tail was 3 when it ran; this matches. - tx.send(CommitLogPageMsg { - kind: CommitLogFetchKind::Tail, - skip: 3, - page_size: 5, - result: Ok(vec![fake_entry(3), fake_entry(4)]), - }) - .unwrap(); - - app.poll_commit_log_page_fetch(); - - assert_eq!(app.log_view.commits.len(), 5); - assert_eq!(app.log_view.loaded_count, 5); - // Page was shorter than page_size → end of history reached. - assert!(app.log_view.fully_loaded); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); +fn worker_reply_appends_matching_tail() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "c0"]); + run_git(&path, &["commit", "--allow-empty", "-m", "c1"]); + let mut app = seed_log_app(0, 1, 1); + app.git.repo_path = path; + app.spawn_commit_log_page_fetch(0); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + assert_eq!(app.git.view.log.commits.len(), 2); + assert_eq!(app.git.view.log.loaded_count, 2); } #[test] -fn poll_discards_stale_skip_result() { - let mut app = seed_log_app(3, 5, 1); - app.log_view.pending_fetch = true; - let (tx, rx) = mpsc::channel(); - app.pagination.page_rx = Some(rx); - // skip=2 doesn't match loaded_count=3 → discard (e.g. HEAD changed - // between spawn and reply, resetting pagination). - tx.send(CommitLogPageMsg { - kind: CommitLogFetchKind::Tail, - skip: 2, - page_size: 5, - result: Ok(vec![fake_entry(2), fake_entry(3)]), - }) - .unwrap(); - - app.poll_commit_log_page_fetch(); - - assert_eq!(app.log_view.commits.len(), 3); - assert!(!app.log_view.fully_loaded); - assert!(!app.log_view.pending_fetch); +fn worker_reply_discards_stale_tail() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "c0"]); + let mut app = seed_log_app(1, 1, 1); + app.git.repo_path = path; + app.spawn_commit_log_page_fetch(1); + app.git + .view + .log + .set_commits(vec![fake_entry(9), fake_entry(10)]); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + assert_eq!(app.git.view.log.commits.len(), 2); + assert_eq!(app.git.view.log.commits[0].summary, "c9"); } #[test] @@ -170,23 +145,25 @@ fn refresh_after_head_change_prepends_new_commit() { run_git(&path, &["commit", "-m", "c1"]); let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; // Simulate having loaded the commit list when c0 was still HEAD. - app.log_view + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()[1..].to_vec()); - let prior_oid = app.log_view.commits.first().unwrap().oid; - assert_eq!(app.log_view.commits.len(), 1); - app.log_view.selected = 0; + let prior_oid = app.git.view.log.commits.first().unwrap().oid; + assert_eq!(app.git.view.log.commits.len(), 1); + app.git.view.log.selected = 0; app.refresh_commit_log_after_head_change(); app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); // The fresh c1 commit is prepended; selection shifts so the user keeps // looking at c0. - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[1].oid, prior_oid); - assert_eq!(app.log_view.selected, 1); + assert_eq!(app.git.view.log.commits.len(), 2); + assert_eq!(app.git.view.log.commits[1].oid, prior_oid); + assert_eq!(app.git.view.log.selected, 1); drop(dir); } @@ -208,12 +185,14 @@ fn refresh_after_head_change_keeps_merged_side_branch_commits() { run_git(&path, &["commit", "-m", "c1"]); let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[0].summary, "c1"); + assert_eq!(app.git.view.log.commits.len(), 2); + assert_eq!(app.git.view.log.commits[0].summary, "c1"); run_git( &path, @@ -224,7 +203,9 @@ fn refresh_after_head_change_keeps_merged_side_branch_commits() { app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); let summaries: Vec<_> = app - .log_view + .git + .view + .log .commits .iter() .map(|c| c.summary.as_str()) @@ -233,7 +214,7 @@ fn refresh_after_head_change_keeps_merged_side_branch_commits() { summaries.contains(&"feature"), "merged side-branch commit was dropped: {summaries:?}" ); - assert_eq!(app.log_view.commits.len(), 4); + assert_eq!(app.git.view.log.commits.len(), 4); drop(dir); } @@ -245,26 +226,26 @@ fn refresh_after_head_change_resets_on_divergence() { run_git(&path, &["commit", "-m", "c0"]); let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; // Pretend a prior list whose head no longer exists in the repo — // simulates rebase/reset/branch switch that drops the old chain. let ghost_oid = git2::Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap(); - app.log_view.set_commits(vec![CommitEntry::new( + app.git.view.log.set_commits(vec![CommitEntry::new( ghost_oid, "012345".to_string(), "vanished".to_string(), "T".to_string(), 0, )]); - app.log_view.selected = 0; + app.git.view.log.selected = 0; app.refresh_commit_log_after_head_change(); app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); // c0 from the actual repo replaces the ghost entry. - assert_eq!(app.log_view.commits.len(), 1); - assert_ne!(app.log_view.commits[0].oid, ghost_oid); - assert_eq!(app.log_view.selected, 0); + assert_eq!(app.git.view.log.commits.len(), 1); + assert_ne!(app.git.view.log.commits[0].oid, ghost_oid); + assert_eq!(app.git.view.log.selected, 0); drop(dir); } diff --git a/src/app/tests/diff_file_view.rs b/src/app/tests/diff_file_view.rs index aa0601e7..e792f094 100644 --- a/src/app/tests/diff_file_view.rs +++ b/src/app/tests/diff_file_view.rs @@ -4,50 +4,50 @@ use super::*; fn keep_scroll_clamps_when_new_diff_is_shorter() { let mut app = app_with_files(vec!["a.rs"]); // Seed a long diff and put scroll near the bottom. - app.diff.hunks = vec![ + app.git.view.diff.set_hunks(vec![ context_hunk(&["l1", "l2", "l3", "l4", "l5"]), context_hunk(&["l6", "l7", "l8"]), - ]; - app.diff.scroll = app.diff.max_scroll(); - let prev_scroll = app.diff.scroll; + ]); + app.git.view.diff.scroll = app.git.view.diff.max_scroll(); + let prev_scroll = app.git.view.diff.scroll; assert!(prev_scroll > 1); // Apply a much shorter diff with KeepScroll; scroll must clamp. let shorter = vec![context_hunk(&["only"])]; app.apply_diff_result(Ok(shorter), DiffApply::KeepScroll(prev_scroll)); assert!( - app.diff.scroll <= app.diff.max_scroll(), + app.git.view.diff.scroll <= app.git.view.diff.max_scroll(), "scroll {} exceeded max {}", - app.diff.scroll, - app.diff.max_scroll() + app.git.view.diff.scroll, + app.git.view.diff.max_scroll() ); } #[test] fn toggle_diff_file_view_ignores_selection_outside_filter() { let mut app = app_with_files(vec!["alpha.rs", "bravo.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); + app.git.view.status.search_query.set("alpha"); + app.git.view.status.recompute_filter(); // selected points outside the filter — toggle must refuse to open // a file view rather than loading the hidden entry. - app.status_view.selected = 1; + app.git.view.status.selected = 1; app.toggle_diff_file_view(); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); + assert!(app.git.view.diff.file_view.key.is_none()); } #[test] fn cycling_the_diff_view_walks_all_three_and_returns_to_the_start() { let mut app = app_with_files(vec!["a.rs"]); - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); app.cycle_diff_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); + assert_eq!(app.git.view.diff.view, DiffPaneView::Split); app.cycle_diff_view(); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); app.cycle_diff_view(); assert_eq!( - app.diff.view, + app.git.view.diff.view, DiffPaneView::Diff, "the cycle must close so one key can reach every view" ); @@ -58,16 +58,16 @@ fn cycling_skips_the_file_view_when_there_is_nothing_to_open() { // Selection outside the filter leaves no resolvable file, the same gate // that makes `v` a no-op. Skipping keeps the press from doing nothing. let mut app = app_with_files(vec!["alpha.rs", "bravo.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); - app.status_view.selected = 1; + app.git.view.status.search_query.set("alpha"); + app.git.view.status.recompute_filter(); + app.git.view.status.selected = 1; assert!(!app.can_open_file_view()); app.cycle_diff_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); + assert_eq!(app.git.view.diff.view, DiffPaneView::Split); app.cycle_diff_view(); assert_eq!( - app.diff.view, + app.git.view.diff.view, DiffPaneView::Diff, "with no file to show the cycle is unified <-> split" ); @@ -78,12 +78,12 @@ fn cycling_the_diff_view_does_nothing_in_tree_mode() { // Tree mode's right pane is always the raw file preview, so there is no // cycle to walk — matching `v`/`s`. let mut app = app_with_files(vec!["a.rs"]); - app.mode = ViewMode::Tree; - app.diff.view = DiffPaneView::File; + app.git.view.mode = ViewMode::Tree; + app.git.view.diff.view = DiffPaneView::File; app.cycle_diff_view(); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); } #[test] @@ -92,70 +92,70 @@ fn toggle_diff_split_view_round_trips_and_overrides_file_view() { // Diff → Split → Diff. app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); + assert_eq!(app.git.view.diff.view, DiffPaneView::Split); app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); // From the file overlay, the split toggle switches straight to Split // rather than back to the unified diff. - app.diff.view = DiffPaneView::File; + app.git.view.diff.view = DiffPaneView::File; app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); + assert_eq!(app.git.view.diff.view, DiffPaneView::Split); } #[test] fn keep_scroll_preserves_open_file_view() { let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["l1", "l2"])]; - app.diff.scroll = 1; - app.diff.file_view = seeded_file_view("a.rs"); - app.diff.view = DiffPaneView::File; + app.git + .view + .diff + .set_hunks(vec![context_hunk(&["l1", "l2"])]); + app.git.view.diff.scroll = 1; + app.git.view.diff.file_view = seeded_file_view("a.rs"); + app.git.view.diff.view = DiffPaneView::File; // Same file refresh through KeepScroll must leave the file view // alone — only Reset paths should invalidate it. let fresh = vec![context_hunk(&["l1", "l2", "l3"])]; - app.apply_diff_result(Ok(fresh), DiffApply::KeepScroll(app.diff.scroll)); + app.apply_diff_result(Ok(fresh), DiffApply::KeepScroll(app.git.view.diff.scroll)); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); assert_eq!( - app.diff.file_view.key, + app.git.view.diff.file_view.key, Some(FileViewKey::Status("a.rs".into())) ); - assert_eq!(app.diff.file_view.scroll, 1); - assert_eq!(app.diff.file_view.scroll_x, 4); + assert_eq!(app.git.view.diff.file_view.scroll, 1); + assert_eq!(app.git.view.diff.file_view.scroll_x, 4); } #[test] fn clear_diff_state_invalidates_open_file_view() { let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["l1"])]; - app.diff.file_view = seeded_file_view("a.rs"); - app.diff.view = DiffPaneView::File; + app.git.view.diff.set_hunks(vec![context_hunk(&["l1"])]); + app.git.view.diff.file_view = seeded_file_view("a.rs"); + app.git.view.diff.view = DiffPaneView::File; // toggle_mode and other reset paths route through clear_diff_state // — that single call must wipe the file view to its default. app.clear_diff_state(); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); - assert!(app.diff.file_view.content.is_empty()); - assert_eq!(app.diff.file_view.scroll, 0); - assert_eq!(app.diff.file_view.scroll_x, 0); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); + assert!(app.git.view.diff.file_view.key.is_none()); + assert!(app.git.view.diff.file_view.content.is_empty()); + assert_eq!(app.git.view.diff.file_view.scroll, 0); + assert_eq!(app.git.view.diff.file_view.scroll_x, 0); } #[test] fn snapshot_refresh_with_no_filter_matches_clears_file_view() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); - app.diff.hunks = vec![context_hunk(&["stale"])]; - app.diff.file_view = seeded_file_view("bar.rs"); - app.diff.view = DiffPaneView::File; + let mut app = app_with_files(vec!["bar.rs"]); + app.git.snapshot = snapshot; + app.git.view.status.search_query.set("bar"); + app.git.view.status.recompute_filter(); + app.git.view.diff.set_hunks(vec![context_hunk(&["stale"])]); + app.git.view.diff.file_view = seeded_file_view("bar.rs"); + app.git.view.diff.view = DiffPaneView::File; tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -176,9 +176,9 @@ fn snapshot_refresh_with_no_filter_matches_clears_file_view() { // No filter matches the new snapshot, so the diff and file view // both need to drop their stale handles on the gone path. assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); + assert!(app.git.view.diff.hunks().is_empty()); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); + assert!(app.git.view.diff.file_view.key.is_none()); } #[test] @@ -186,14 +186,14 @@ fn enabling_wrap_drops_the_stale_horizontal_offset() { // ratatui ignores scroll.x while wrapping, so an offset left behind would // reappear the moment wrap is switched back off. let mut app = app_with_files(vec!["a.rs"]); - app.diff.scroll_x = 12; - app.diff.file_view.scroll_x = 9; + app.git.view.diff.scroll_x = 12; + app.git.view.diff.file_view.scroll_x = 9; app.toggle_diff_wrap(); - assert!(app.diff.wrap); - assert_eq!(app.diff.scroll_x, 0); - assert_eq!(app.diff.file_view.scroll_x, 0); + assert!(app.git.view.diff.wrap); + assert_eq!(app.git.view.diff.scroll_x, 0); + assert_eq!(app.git.view.diff.file_view.scroll_x, 0); } #[test] @@ -202,9 +202,9 @@ fn disabling_wrap_leaves_the_offset_where_it_was_reset() { app.toggle_diff_wrap(); app.toggle_diff_wrap(); - assert!(!app.diff.wrap); + assert!(!app.git.view.diff.wrap); assert_eq!( - app.diff.scroll_x, 0, + app.git.view.diff.scroll_x, 0, "turning wrap off must not resurrect a pre-wrap offset" ); } diff --git a/src/app/tests/fullscreen.rs b/src/app/tests/fullscreen.rs index 88b02bef..6f60e883 100644 --- a/src/app/tests/fullscreen.rs +++ b/src/app/tests/fullscreen.rs @@ -15,7 +15,7 @@ fn focus_list_jumps_and_exits_competing_fullscreens() { assert_eq!(app.focus, Focus::FileList); assert!(!app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); } #[test] @@ -39,11 +39,11 @@ fn switch_pane_exits_diff_fullscreen() { title: "shell".into(), }]; app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); app.switch_pane(0); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::Terminal); assert_eq!(app.terminal.active, 0); } @@ -177,12 +177,12 @@ fn toggle_diff_fullscreen_sets_flag_and_focuses_diff_viewer() { app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::DiffViewer); app.toggle_diff_fullscreen(); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); // Exiting zoom leaves focus on DiffViewer (no reason to bounce back). assert_eq!(app.focus, Focus::DiffViewer); } @@ -199,7 +199,7 @@ fn toggle_diff_fullscreen_exits_terminal_fullscreen() { app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); assert!(!app.terminal.fullscreen.fills_body()); assert_eq!(app.focus, Focus::DiffViewer); } @@ -212,12 +212,12 @@ fn toggle_terminal_fullscreen_exits_diff_fullscreen() { title: "shell".into(), }]; app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); app.toggle_terminal_fullscreen(); assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::Terminal); } @@ -286,11 +286,11 @@ fn toggle_list_fullscreen_sets_flag_and_focuses_file_list() { fn toggle_list_fullscreen_exits_diff_fullscreen() { let mut app = app_with_files(vec![]); app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); app.toggle_list_fullscreen(); assert!(app.list_fullscreen); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::FileList); } diff --git a/src/app/tests/git_view_manager_contract.rs b/src/app/tests/git_view_manager_contract.rs new file mode 100644 index 00000000..f70c63eb --- /dev/null +++ b/src/app/tests/git_view_manager_contract.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn manager_constructor_owns_repository_runtime_state() { + let (snapshot, _snapshot_tx) = dummy_snapshot_channel(); + let (tree_watch, _tree_tx) = dummy_tree_watcher(); + let manager = GitViewManager::from_test_parts("repo".to_string(), snapshot, tree_watch); + + assert_eq!(manager.repo_path(), "repo"); + assert!(manager.pending_snapshot().is_none()); + assert_eq!(manager.view().mode(), ViewMode::Status); +} + +#[test] +fn adopting_an_id_does_not_replace_repository_view_state() { + let (snapshot, _snapshot_tx) = dummy_snapshot_channel(); + let (tree_watch, _tree_tx) = dummy_tree_watcher(); + let mut manager = GitViewManager::from_test_parts("repo".to_string(), snapshot, tree_watch); + manager.view_mut().status_mut().selected = 4; + + manager.adopt_repo_id("opaque-id".to_string()); + + assert_eq!(manager.repo_id(), Some("opaque-id")); + assert_eq!(manager.view().status().selected, 4); +} diff --git a/src/app/tests/head_change.rs b/src/app/tests/head_change.rs index 6a682e55..f08aace3 100644 --- a/src/app/tests/head_change.rs +++ b/src/app/tests/head_change.rs @@ -21,18 +21,17 @@ fn head_change_in_log_mode_reloads_commit_list() { run_git(&path, &["commit", "--allow-empty", "-m", "second"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.log_view.selected = 0; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); - assert_eq!(app.log_view.commits.len(), 2); + app.git.view.log.selected = 0; + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); + assert_eq!(app.git.view.log.commits.len(), 2); // Make a new commit in the same repo (simulates the terminal pane // running `git commit`). @@ -44,11 +43,11 @@ fn head_change_in_log_mode_reloads_commit_list() { app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); assert_eq!( - app.log_view.commits.len(), + app.git.view.log.commits.len(), 3, "commit list should auto-refresh on HEAD change" ); - assert_eq!(app.log_view.commits[0].summary, "third"); + assert_eq!(app.git.view.log.commits[0].summary, "third"); } #[test] @@ -57,19 +56,18 @@ fn head_change_in_status_mode_does_not_reload() { run_git(&path, &["commit", "--allow-empty", "-m", "first"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); // Pre-load a stale 1-entry list; in Status mode it must NOT be // refreshed even when HEAD moves. - app.log_view + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); - assert_eq!(app.log_view.commits.len(), 1); - assert_eq!(app.mode, ViewMode::Status); + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); + assert_eq!(app.git.view.log.commits.len(), 1); + assert_eq!(app.git.view.mode, ViewMode::Status); run_git(&path, &["commit", "--allow-empty", "-m", "second"]); @@ -78,7 +76,7 @@ fn head_change_in_status_mode_does_not_reload() { app.poll_snapshot(); assert_eq!( - app.log_view.commits.len(), + app.git.view.log.commits.len(), 1, "Status mode must not eagerly refresh the (hidden) commit list" ); @@ -90,17 +88,16 @@ fn toggling_log_after_status_head_change_reloads_stale_cache() { run_git(&path, &["commit", "--allow-empty", "-m", "first"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Status; - app.log_view + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Status; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); - assert_eq!(app.log_view.commits[0].summary, "first"); + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); + assert_eq!(app.git.view.log.commits[0].summary, "first"); run_git(&path, &["commit", "--allow-empty", "-m", "second"]); tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) @@ -110,19 +107,22 @@ fn toggling_log_after_status_head_change_reloads_stale_cache() { // Status mode leaves the hidden list untouched, but records the new // HEAD. Entering Log mode must notice the mismatch and reconcile page 0 // rather than reusing the stale cached page as-is. - assert_eq!(app.log_view.commits.len(), 1); - assert_eq!(app.log_view.commits[0].summary, "first"); + assert_eq!(app.git.view.log.commits.len(), 1); + assert_eq!(app.git.view.log.commits[0].summary, "first"); app.toggle_mode(); app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - assert_eq!(app.mode, ViewMode::Log); - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[0].summary, "second"); - assert_eq!(app.log_view.selected, 1); - assert_eq!(app.log_view.commits[app.log_view.selected].summary, "first"); - assert!(app.log_view.fully_loaded); - assert!(app.pagination.page_rx.is_none()); + assert_eq!(app.git.view.mode, ViewMode::Log); + assert_eq!(app.git.view.log.commits.len(), 2); + assert_eq!(app.git.view.log.commits[0].summary, "second"); + assert_eq!(app.git.view.log.selected, 1); + assert_eq!( + app.git.view.log.commits[app.git.view.log.selected].summary, + "first" + ); + assert!(app.git.view.log.fully_loaded); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -132,19 +132,18 @@ fn head_change_preserves_selected_commit_by_oid() { run_git(&path, &["commit", "--allow-empty", "-m", "second"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); // Select the older commit at the bottom. - app.log_view.selected = 1; - let prior_oid = app.log_view.commits[1].oid; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.git.view.log.selected = 1; + let prior_oid = app.git.view.log.commits[1].oid; + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); run_git(&path, &["commit", "--allow-empty", "-m", "third"]); @@ -155,9 +154,12 @@ fn head_change_preserves_selected_commit_by_oid() { // The 'first' commit now sits at index 2 because a new commit is // prepended. Selection must follow it by oid, not by index. - assert_eq!(app.log_view.commits.len(), 3); - assert_eq!(app.log_view.selected, 2); - assert_eq!(app.log_view.commits[app.log_view.selected].oid, prior_oid); + assert_eq!(app.git.view.log.commits.len(), 3); + assert_eq!(app.git.view.log.selected, 2); + assert_eq!( + app.git.view.log.commits[app.git.view.log.selected].oid, + prior_oid + ); } #[test] @@ -167,17 +169,16 @@ fn head_change_falls_back_to_top_when_prior_oid_gone() { run_git(&path, &["commit", "--allow-empty", "-m", "second"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.log_view.selected = 0; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.git.view.log.selected = 0; + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); // Reset to before the second commit so the prior HEAD oid is gone, // then add a different commit on top. @@ -191,8 +192,8 @@ fn head_change_falls_back_to_top_when_prior_oid_gone() { // The original selected commit ('second') no longer exists; selection // must fall back to the newest (index 0). - assert_eq!(app.log_view.selected, 0); - assert_eq!(app.log_view.commits[0].summary, "other"); + assert_eq!(app.git.view.log.selected, 0); + assert_eq!(app.git.view.log.commits[0].summary, "other"); } #[test] @@ -202,18 +203,17 @@ fn head_change_clears_drill_down_when_commit_gone() { run_git(&path, &["commit", "--allow-empty", "-m", "doomed"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.log_view.selected = 0; // 'doomed' commit at top - app.log_view.drill_down = true; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.git.view.log.selected = 0; // 'doomed' commit at top + app.git.view.log.drill_down = true; + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); // Drop the selected commit via reset, then advance HEAD with a new one. run_git(&path, &["reset", "--hard", "HEAD~1"]); @@ -226,7 +226,7 @@ fn head_change_clears_drill_down_when_commit_gone() { // The drill-down's commit oid is gone, so drill-down must collapse // and the view drops back to the commit-level diff. - assert!(!app.log_view.drill_down); + assert!(!app.git.view.log.drill_down); } #[test] @@ -235,16 +235,13 @@ fn initial_snapshot_does_not_trigger_commit_log_reload() { run_git(&path, &["commit", "--allow-empty", "-m", "first"]); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; // No prior commits loaded; last_head_oid = None (default). - assert!(app.log_view.commits.is_empty()); - assert!(app.pagination.last_head_oid.is_none()); + assert!(app.git.view.log.commits.is_empty()); + assert!(app.observed_head_for_test().is_none()); tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) .unwrap(); @@ -253,6 +250,6 @@ fn initial_snapshot_does_not_trigger_commit_log_reload() { // First snapshot must NOT eagerly fetch the commit log — that's // toggle_mode's / restore_log_session's job. We only refresh on // subsequent HEAD changes. - assert!(app.log_view.commits.is_empty()); - assert!(app.pagination.last_head_oid.is_some()); + assert!(app.git.view.log.commits.is_empty()); + assert!(app.observed_head_for_test().is_some()); } diff --git a/src/app/tests/helpers.rs b/src/app/tests/helpers.rs index 2b79be19..cc9644f9 100644 --- a/src/app/tests/helpers.rs +++ b/src/app/tests/helpers.rs @@ -29,57 +29,38 @@ pub(crate) fn dummy_tree_watcher() -> ( pub(crate) fn app_with_files(files: Vec<&str>) -> App { let (snapshot, _tx) = dummy_snapshot_channel(); let (tree_watch, _tw_tx) = dummy_tree_watcher(); - let mut status_view = StatusView { - files: files - .into_iter() - .map(|path| ChangedFile::unstaged_only(path.to_string(), StatusKind::Modified)) - .collect(), - ..Default::default() - }; - status_view.recompute_filter(); - App { - mode: ViewMode::Status, - status_view, - diff: DiffPane::default(), - focus: Focus::FileList, - notice: None, - repo_id: None, - repo_path: ".".to_string(), - log_view: LogView::default(), - tree_view: TreeView::default(), - terminal: TerminalState::new(None, false), - tracking: None, - snapshot, - pending_snapshot: None, - tree_watch, - tree_dirty: Default::default(), - tree_dirty_all: false, - pending_selection: None, - // The fresh-launch rule `App::new` starts with: focus the terminals - // when they arrive. - pending_terminal: Some(crate::workspace::persistence::SessionState { - focus: Some(Focus::Terminal), - ..Default::default() - }), - repo_cache: None, - cfg_agent_indicator: crate::config::AgentIndicatorConfig { - auto_follow: true, - ..crate::config::AgentIndicatorConfig::default() - }, - cfg_tree: crate::config::TreeConfig::default(), - pagination: CommitLogPagination::with_config( - crate::config::LogConfig::default().commit_log_page_size, - crate::config::LogConfig::default().commit_log_prefetch_threshold, - ), - auto_follow: AutoFollow::default(), - list_fullscreen: false, - branch_name: None, - log_decorations: Default::default(), - last_refs_fingerprint: None, - interaction: InteractionState::new(KeyEvent::new( - KeyCode::Char('f'), - KeyModifiers::CONTROL, - )), + let mut git = GitViewManager::from_test_parts(".".to_string(), snapshot, tree_watch); + git.agent_indicator.auto_follow = true; + git.view.status.files = files + .into_iter() + .map(|path| ChangedFile::unstaged_only(path.to_string(), StatusKind::Modified)) + .collect(); + git.view.status.recompute_filter(); + App::from_test_parts( + git, + TerminalState::new(None, false), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + ) +} + +impl App { + pub(crate) fn from_test_parts( + git: GitViewManager, + terminal: TerminalState, + leader: KeyEvent, + ) -> Self { + Self { + git, + focus: Focus::FileList, + notice: None, + terminal, + pending_terminal: Some(crate::workspace::persistence::SessionState { + focus: Some(Focus::Terminal), + ..Default::default() + }), + list_fullscreen: false, + interaction: InteractionState::new(leader), + } } } diff --git a/src/app/tests/log_drill.rs b/src/app/tests/log_drill.rs index a2d4d3ef..a53e09c4 100644 --- a/src/app/tests/log_drill.rs +++ b/src/app/tests/log_drill.rs @@ -6,17 +6,20 @@ fn log_drill_in_clears_stale_diff_for_empty_commit() { run_git(&path, &["commit", "--allow-empty", "-m", "empty"]); let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view + app.git.repo_path = path.clone(); + app.git.view.mode = ViewMode::Log; + app.git + .view + .log .set_commits(load_commit_log(&open_repo(&path), 1).unwrap()); - app.diff.hunks = vec![context_hunk(&["stale"])]; - app.log_view.diff_title = "stale".to_string(); + app.git.view.diff.set_hunks(vec![context_hunk(&["stale"])]); + app.git.view.log.diff_title = "stale".to_string(); app.log_drill_in(); + app.flush_git_loads_for_test(Duration::from_secs(2)); - assert!(app.log_view.drill_down); - assert!(app.log_view.commit_files.is_empty()); - assert!(app.diff.hunks.is_empty()); - assert!(app.log_view.diff_title.contains("empty")); + assert!(app.git.view.log.drill_down); + assert!(app.git.view.log.commit_files.is_empty()); + assert!(app.git.view.diff.hunks().is_empty()); + assert!(app.git.view.log.diff_title.contains("empty")); } diff --git a/src/app/tests/log_search.rs b/src/app/tests/log_search.rs index 3ffd2277..5fee6ccd 100644 --- a/src/app/tests/log_search.rs +++ b/src/app/tests/log_search.rs @@ -14,13 +14,13 @@ fn named_commit(summary: &str) -> CommitEntry { #[test] fn commit_search_filters_summaries_and_clamps_selection() { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.set_commits(vec![ + app.git.view.mode = ViewMode::Log; + app.git.view.log.set_commits(vec![ named_commit("feat: search bar"), named_commit("docs: readme"), named_commit("fix: another search edge case"), ]); - app.log_view.selected = 1; + app.git.view.log.selected = 1; app.start_log_search(); app.log_search_push('s'); @@ -32,24 +32,24 @@ fn commit_search_filters_summaries_and_clamps_selection() { // "docs: readme" no longer matches → selection snaps to first match. assert_eq!(app.log_commit_filtered_indices(), &[0, 2]); - assert_eq!(app.log_view.selected, 0); + assert_eq!(app.git.view.log.selected, 0); app.cancel_log_search(); assert_eq!(app.log_commit_filtered_indices(), &[0, 1, 2]); - assert!(app.log_view.commit_search_query.is_empty()); + assert!(app.git.view.log.commit_search_query.is_empty()); } #[test] fn maybe_prefetch_suppressed_while_commit_search_active() { // 10 loaded, threshold 5 — selected at 6 would normally spawn a fetch. let mut app = seed_log_app(10, 5, 5); - app.log_view.selected = 6; - app.log_view.commit_search_active = true; + app.git.view.log.selected = 6; + app.git.view.log.commit_search_active = true; app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.git.view.log.pending_fetch); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -60,21 +60,20 @@ fn cancel_log_search_resumes_prefetch() { run_git(&path, &["commit", "-m", "c"]); let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; + app.git.repo_path = path.clone(); + app.git.view.log.selected = 6; // Open the search bar so the prefetch gate is engaged. app.start_log_search(); app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); + assert!(!app.git.view.log.pending_fetch); // Cancelling the search must re-call maybe_prefetch so the deferred // tail fetch can run now that the gate is lifted. app.cancel_log_search(); - assert!(app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_some()); + assert!(app.git.view.log.pending_fetch); + assert!(app.commit_log_fetch_pending()); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } @@ -88,33 +87,32 @@ fn confirm_log_search_with_query_resumes_prefetch() { run_git(&path, &["commit", "-m", "c"]); let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; + app.git.repo_path = path.clone(); + app.git.view.log.selected = 6; app.start_log_search(); app.log_search_push('c'); // every fake summary matches. - assert!(!app.log_view.pending_fetch); + assert!(!app.git.view.log.pending_fetch); app.confirm_log_search(); - assert!(!app.log_view.commit_search_active); - assert_eq!(app.log_view.commit_search_query.as_str(), "c"); - assert!(app.log_view.pending_fetch); + assert!(!app.git.view.log.commit_search_active); + assert_eq!(app.git.view.log.commit_search_query.as_str(), "c"); + assert!(app.git.view.log.pending_fetch); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } #[test] fn drilldown_file_search_filters_paths_and_clamps_selection() { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.drill_down = true; - app.log_view.set_commit_files(vec![ + app.git.view.mode = ViewMode::Log; + app.git.view.log.drill_down = true; + app.git.view.log.set_commit_files(vec![ ChangedFile::unstaged_only("src/lib.rs".into(), StatusKind::Modified), ChangedFile::unstaged_only("README.md".into(), StatusKind::Modified), ChangedFile::unstaged_only("src/main.rs".into(), StatusKind::Modified), ]); - app.log_view.file_selected = 1; + app.git.view.log.file_selected = 1; app.start_log_search(); app.log_search_push('s'); @@ -123,10 +121,10 @@ fn drilldown_file_search_filters_paths_and_clamps_selection() { // README.md drops out → selection snaps to first matching path. assert_eq!(app.log_file_filtered_indices(), &[0, 2]); - assert_eq!(app.log_view.file_selected, 0); - assert!(app.log_view.file_search_active); + assert_eq!(app.git.view.log.file_selected, 0); + assert!(app.git.view.log.file_search_active); app.cancel_log_search(); assert_eq!(app.log_file_filtered_indices(), &[0, 1, 2]); - assert!(app.log_view.file_search_query.is_empty()); + assert!(app.git.view.log.file_search_query.is_empty()); } diff --git a/src/app/tests/mod.rs b/src/app/tests/mod.rs index 14a28990..d888bf25 100644 --- a/src/app/tests/mod.rs +++ b/src/app/tests/mod.rs @@ -1,13 +1,12 @@ mod helpers; // `use super::*` re-exports app.rs's `use` declarations and public items -// (App, AutoFollow, Focus, ViewMode, Notice, NoticeKind, DiffPaneView, -// FileViewKey, FileViewState, CommitLogPagination, SnapshotChannel, etc.) +// (App, Focus, ViewMode, Notice, NoticeKind, DiffPaneView, FileViewKey, +// FileViewState, SnapshotChannel, etc.) // so every test submodule can pull them in with `use super::*;`. use super::diff_load::DiffApply; use super::strip_escape_sequences; use super::*; -use crate::app::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; use crate::git::diff::{ChangedFile, CommitEntry, RepoSnapshot, StatusKind, load_commit_log}; use crate::runtime::snapshot::SnapshotMsg; use crate::runtime::terminal::{PaneInfo, SCROLLBACK_LINES, TerminalFullscreen}; @@ -15,20 +14,23 @@ use crate::test_util::{make_repo, open_repo, run_git}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; use std::path::Path; -use std::sync::mpsc; use std::time::{Duration, Instant, SystemTime}; +mod app_repository_integration; +mod async_load; mod auto_follow; mod clamp_pane; mod commit_log; mod diff_file_view; mod fullscreen; +mod git_view_manager_contract; mod head_change; mod leader_notice; mod log_drill; mod log_search; mod mode_toggle; mod pane; +mod repository_view_contract; mod scroll_misc; mod session_restore; mod session_restore_fullscreen; diff --git a/src/app/tests/mode_toggle.rs b/src/app/tests/mode_toggle.rs index 0f085a5e..9ee60604 100644 --- a/src/app/tests/mode_toggle.rs +++ b/src/app/tests/mode_toggle.rs @@ -1,9 +1,9 @@ use super::*; pub(super) fn seed_cached_commit_log(app: &mut App) { - app.log_view.set_commits(vec![fake_entry(0)]); - app.log_view.fully_loaded = true; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.git.view.log.set_commits(vec![fake_entry(0)]); + app.git.view.log.fully_loaded = true; + app.set_observed_head_for_test(app.git.view.log.commits.first().map(|c| c.oid)); } fn fake_entry(time: i64) -> CommitEntry { @@ -29,9 +29,9 @@ fn toggle_mode_from_terminal_fullscreen_reveals_file_list() { app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.git.view.mode, ViewMode::Log); assert!(!app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::FileList); } @@ -40,12 +40,12 @@ fn toggle_mode_from_diff_fullscreen_reveals_file_list() { let mut app = app_with_files(vec![]); seed_cached_commit_log(&mut app); app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); - assert!(!app.diff.fullscreen); + assert_eq!(app.git.view.mode, ViewMode::Log); + assert!(!app.git.view.diff.fullscreen); assert!(!app.terminal.fullscreen.fills_body()); assert_eq!(app.focus, Focus::FileList); } @@ -58,11 +58,11 @@ fn toggle_mode_in_split_layout_keeps_focus() { app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.git.view.mode, ViewMode::Log); assert_eq!(app.focus, Focus::DiffViewer); app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); assert_eq!(app.focus, Focus::DiffViewer); } diff --git a/src/app/tests/pane.rs b/src/app/tests/pane.rs index 92f61aa8..e643725e 100644 --- a/src/app/tests/pane.rs +++ b/src/app/tests/pane.rs @@ -37,12 +37,12 @@ fn open_new_pane_moves_focus_to_new_terminal() { fn open_new_pane_exits_competing_fullscreen() { let mut app = app_with_fake_backend(); app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); app.open_new_pane(); assert_eq!(app.focus, Focus::Terminal); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert!(!app.list_fullscreen); } diff --git a/src/app/tests/repository_view_contract.rs b/src/app/tests/repository_view_contract.rs new file mode 100644 index 00000000..c570a5a3 --- /dev/null +++ b/src/app/tests/repository_view_contract.rs @@ -0,0 +1,27 @@ +use super::*; + +#[test] +fn repository_view_keeps_shared_diff_when_switching_modes() { + let mut view = RepositoryView::default(); + view.diff_mut().file_view = seeded_file_view("src/lib.rs"); + + view.set_mode(ViewMode::Tree); + view.set_mode(ViewMode::Status); + + assert_eq!( + view.diff().file_view.key, + Some(FileViewKey::Status("src/lib.rs".to_string())) + ); +} + +#[test] +fn pending_selection_belongs_to_repository_view() { + let mut view = RepositoryView::default(); + view.set_pending_selection(Some(("src/main.rs".to_string(), 7))); + + assert_eq!( + view.take_pending_selection(), + Some(("src/main.rs".to_string(), 7)) + ); + assert!(view.take_pending_selection().is_none()); +} diff --git a/src/app/tests/scroll_misc.rs b/src/app/tests/scroll_misc.rs index 3f3b0cbe..e0b9de30 100644 --- a/src/app/tests/scroll_misc.rs +++ b/src/app/tests/scroll_misc.rs @@ -3,20 +3,20 @@ use super::*; #[test] fn move_selected_in_filter_resets_horizontal_scroll() { let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.file_scroll_x = 12; + app.git.view.status.file_scroll_x = 12; app.move_selected_in_filter(1); - assert_eq!(app.status_view.selected, 1); - assert_eq!(app.status_view.file_scroll_x, 0); + assert_eq!(app.git.view.status.selected, 1); + assert_eq!(app.git.view.status.file_scroll_x, 0); } #[test] fn log_select_down_resets_commit_scroll() { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; + app.git.view.mode = ViewMode::Log; // Seed through `set_commits` so the search filter cache is built; // log navigation walks the filter cache (empty query → 0..len), // which matches the production code path. - app.log_view.set_commits(vec![ + app.git.view.log.set_commits(vec![ CommitEntry::new( git2::Oid::ZERO_SHA1, "0000000".into(), @@ -32,53 +32,56 @@ fn log_select_down_resets_commit_scroll() { 0, ), ]); - app.log_view.commit_scroll_x = 9; + app.git.view.log.commit_scroll_x = 9; app.log_select_down(); - assert_eq!(app.log_view.selected, 1); - assert_eq!(app.log_view.commit_scroll_x, 0); + assert_eq!(app.git.view.log.selected, 1); + assert_eq!(app.git.view.log.commit_scroll_x, 0); } #[test] fn log_file_select_down_resets_file_scroll() { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.drill_down = true; - app.log_view.set_commits(vec![CommitEntry::new( + app.git.view.mode = ViewMode::Log; + app.git.view.log.drill_down = true; + app.git.view.log.set_commits(vec![CommitEntry::new( git2::Oid::ZERO_SHA1, "0000000".into(), "first".into(), "T".into(), 0, )]); - app.log_view.set_commit_files(vec![ + app.git.view.log.set_commit_files(vec![ ChangedFile::unstaged_only("x.rs".into(), StatusKind::Modified), ChangedFile::unstaged_only("y.rs".into(), StatusKind::Modified), ]); - app.log_view.file_scroll_x = 7; + app.git.view.log.file_scroll_x = 7; app.log_file_select_down(); - assert_eq!(app.log_view.file_selected, 1); - assert_eq!(app.log_view.file_scroll_x, 0); + assert_eq!(app.git.view.log.file_selected, 1); + assert_eq!(app.git.view.log.file_scroll_x, 0); } #[test] fn diff_scroll_routes_to_file_view_when_in_file_mode() { let mut app = app_with_files(vec![]); - app.diff.scroll_x = 12; - app.diff.file_view.scroll_x = 4; - app.diff.view = DiffPaneView::File; + app.git.view.diff.scroll_x = 12; + app.git.view.diff.file_view.scroll_x = 4; + app.git.view.diff.view = DiffPaneView::File; - app.diff.scroll_right(); - assert_eq!(app.diff.scroll_x, 12, "diff scroll_x must not change"); - assert_eq!(app.diff.file_view.scroll_x, 8); + app.git.view.diff.scroll_right(); + assert_eq!( + app.git.view.diff.scroll_x, 12, + "diff scroll_x must not change" + ); + assert_eq!(app.git.view.diff.file_view.scroll_x, 8); - app.diff.scroll_left(); - assert_eq!(app.diff.file_view.scroll_x, 4); + app.git.view.diff.scroll_left(); + assert_eq!(app.git.view.diff.file_view.scroll_x, 4); - app.diff.view = DiffPaneView::Diff; - app.diff.scroll_right(); - assert_eq!(app.diff.scroll_x, 16); + app.git.view.diff.view = DiffPaneView::Diff; + app.git.view.diff.scroll_right(); + assert_eq!(app.git.view.diff.scroll_x, 16); assert_eq!( - app.diff.file_view.scroll_x, 4, + app.git.view.diff.file_view.scroll_x, 4, "file_view scroll_x must not change in diff mode" ); } @@ -86,13 +89,13 @@ fn diff_scroll_routes_to_file_view_when_in_file_mode() { #[test] fn selected_filtered_status_file_returns_none_outside_filter() { let mut app = app_with_files(vec!["alpha.rs", "bravo.rs", "charlie.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); + app.git.view.status.search_query.set("alpha"); + app.git.view.status.recompute_filter(); // Filter only matches index 0; selecting index 2 must return None. - app.status_view.selected = 2; + app.git.view.status.selected = 2; assert!(app.selected_filtered_status_file().is_none()); - app.status_view.selected = 0; + app.git.view.status.selected = 0; assert_eq!( app.selected_filtered_status_file().map(|f| f.path.as_str()), Some("alpha.rs") diff --git a/src/app/tests/session_restore.rs b/src/app/tests/session_restore.rs index 6bb9f670..39fc12aa 100644 --- a/src/app/tests/session_restore.rs +++ b/src/app/tests/session_restore.rs @@ -1,6 +1,114 @@ use super::mode_toggle::seed_cached_commit_log; use super::*; +fn repo_with_scrolled_diff() -> (tempfile::TempDir, String) { + let (dir, path) = make_repo(); + let file = Path::new(&path).join("a.rs"); + std::fs::write( + &file, + (0..40).map(|n| format!("old {n}\n")).collect::(), + ) + .unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "base"]); + std::fs::write( + &file, + (0..40).map(|n| format!("new {n}\n")).collect::(), + ) + .unwrap(); + (dir, path) +} + +#[test] +fn status_restore_with_an_existing_list_applies_scroll_after_async_diff() { + let (_dir, path) = repo_with_scrolled_diff(); + let mut app = app_with_files(vec!["a.rs"]); + app.git.repo_path = path; + + app.restore_session(&crate::workspace::persistence::SessionState { + selected_file: Some("a.rs".to_string()), + scroll: 7, + ..Default::default() + }); + app.flush_git_loads_for_test(Duration::from_secs(2)); + + assert_eq!(app.git.view.diff.scroll, 7); +} + +#[test] +fn status_restore_from_first_snapshot_applies_scroll_after_async_diff() { + let (_dir, path) = repo_with_scrolled_diff(); + let mut app = app_with_files(vec![]); + app.git.repo_path = path; + app.restore_session(&crate::workspace::persistence::SessionState { + selected_file: Some("a.rs".to_string()), + scroll: 7, + ..Default::default() + }); + + app.ingest_snapshot( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + refs_fingerprint: 0, + }, + HashMap::new(), + ); + app.flush_git_loads_for_test(Duration::from_secs(2)); + + assert_eq!(app.git.view.diff.scroll, 7); +} + +#[test] +fn status_restore_with_existing_list_survives_same_path_snapshot_replacement() { + let (_dir, path) = repo_with_scrolled_diff(); + let mut app = app_with_files(vec![]); + app.git.repo_path = path; + let old_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1); + let new_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(2); + app.ingest_snapshot( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + refs_fingerprint: 0, + }, + HashMap::from([("a.rs".to_string(), old_mtime)]), + ); + app.flush_git_loads_for_test(Duration::from_secs(2)); + app.restore_session(&crate::workspace::persistence::SessionState { + selected_file: Some("a.rs".to_string()), + scroll: 7, + ..Default::default() + }); + + app.ingest_snapshot( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::TypeChanged, + )], + tracking: None, + head_oid: None, + branch_name: None, + refs_fingerprint: 0, + }, + HashMap::from([("a.rs".to_string(), new_mtime)]), + ); + app.flush_git_loads_for_test(Duration::from_secs(2)); + + assert_eq!(app.git.view.diff.scroll, 7); +} + #[test] fn toggle_mode_in_list_fullscreen_keeps_list_fullscreen() { let mut app = app_with_files(vec![]); @@ -10,7 +118,7 @@ fn toggle_mode_in_list_fullscreen_keeps_list_fullscreen() { app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.git.view.mode, ViewMode::Log); assert!(app.list_fullscreen); assert_eq!(app.focus, Focus::FileList); } @@ -40,7 +148,7 @@ fn toggle_diff_fullscreen_exits_list_fullscreen() { app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); assert!(!app.list_fullscreen); assert_eq!(app.focus, Focus::DiffViewer); } diff --git a/src/app/tests/session_restore_fullscreen.rs b/src/app/tests/session_restore_fullscreen.rs index 5d9353e2..aca1c4e6 100644 --- a/src/app/tests/session_restore_fullscreen.rs +++ b/src/app/tests/session_restore_fullscreen.rs @@ -58,7 +58,7 @@ fn restore_session_diff_fullscreen_forces_diff_focus() { ..Default::default() }); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::DiffViewer); } @@ -78,7 +78,7 @@ fn restore_session_prefers_terminal_fullscreen_over_diff_fullscreen() { }); assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); + assert!(!app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::Terminal); } @@ -86,14 +86,14 @@ fn restore_session_prefers_terminal_fullscreen_over_diff_fullscreen() { fn save_session_round_trips_diff_fullscreen() { let mut app = app_with_files(vec![]); app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); let state = app.save_session(); assert!(state.diff_fullscreen); let mut other = app_with_files(vec![]); other.restore_session(&state); - assert!(other.diff.fullscreen); + assert!(other.git.view.diff.fullscreen); assert_eq!(other.focus, Focus::DiffViewer); } @@ -110,15 +110,16 @@ fn restore_session_keeps_log_scroll_after_loading_commit_diff() { run_git(&path, &["commit", "-m", "init"]); let mut app = app_with_files(vec![]); - app.repo_path = path; + app.git.repo_path = path; app.restore_session(&crate::workspace::persistence::SessionState { mode: Some(ViewMode::Log), scroll: 2, ..Default::default() }); + app.flush_git_loads_for_test(Duration::from_secs(2)); - assert_eq!(app.mode, ViewMode::Log); - assert!(!app.diff.hunks.is_empty()); - assert_eq!(app.diff.scroll, 2); + assert_eq!(app.git.view.mode, ViewMode::Log); + assert!(!app.git.view.diff.hunks().is_empty()); + assert_eq!(app.git.view.diff.scroll, 2); } diff --git a/src/app/tests/snapshot.rs b/src/app/tests/snapshot.rs index c6b4f63a..ffc1cc3b 100644 --- a/src/app/tests/snapshot.rs +++ b/src/app/tests/snapshot.rs @@ -3,11 +3,8 @@ use super::*; #[test] fn drain_snapshot_empties_the_queue_without_applying_it() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["old.rs"]) - }; + let mut app = app_with_files(vec!["old.rs"]); + app.git.snapshot = snapshot; let send = |files: Vec<&str>| { SnapshotMsg::Ok( RepoSnapshot { @@ -30,14 +27,20 @@ fn drain_snapshot_empties_the_queue_without_applying_it() { // The queue is empty (so a hidden project's channel cannot grow), but // no git work ran: the view still shows the pre-snapshot file list. - assert!(app.snapshot.try_recv().is_err(), "queue must be drained"); - assert_eq!(app.status_view.files[0].path, "old.rs"); - assert!(app.pending_snapshot.is_some(), "the tail is held for later"); + assert!( + app.git.snapshot.try_recv().is_err(), + "queue must be drained" + ); + assert_eq!(app.git.view.status.files[0].path, "old.rs"); + assert!( + app.git.pending_snapshot.is_some(), + "the tail is held for later" + ); // Applying it later yields the *last* snapshot, not the first. app.poll_snapshot(); - assert_eq!(app.status_view.files[0].path, "second.rs"); - assert!(app.pending_snapshot.is_none(), "pending is consumed"); + assert_eq!(app.git.view.status.files[0].path, "second.rs"); + assert!(app.git.pending_snapshot.is_none(), "pending is consumed"); } #[test] @@ -46,20 +49,21 @@ fn a_saved_mode_lands_immediately_and_survives_being_changed() { // whatever the user had picked in between. Now the mode is applied on // the spot, so a later change is simply the newer choice. let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; app.restore_session(&crate::workspace::persistence::SessionState { mode: Some(ViewMode::Tree), ..Default::default() }); - assert_eq!(app.mode, ViewMode::Tree, "applied without a snapshot"); + assert_eq!( + app.git.view.mode, + ViewMode::Tree, + "applied without a snapshot" + ); app.toggle_mode(); - let chosen = app.mode; + let chosen = app.git.view.mode; tx.send(SnapshotMsg::Ok( RepoSnapshot { files: Vec::new(), @@ -73,7 +77,10 @@ fn a_saved_mode_lands_immediately_and_survives_being_changed() { .unwrap(); app.poll_snapshot(); - assert_eq!(app.mode, chosen, "the snapshot must not undo the choice"); + assert_eq!( + app.git.view.mode, chosen, + "the snapshot must not undo the choice" + ); } #[test] @@ -81,17 +88,17 @@ fn a_saved_selection_is_restored_by_the_first_snapshot() { // The one part that has to wait: it names a file the changed-file list // has not delivered yet. It rides the ordinary path-preservation code. let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; app.restore_session(&crate::workspace::persistence::SessionState { selected_file: Some("b.rs".to_string()), ..Default::default() }); - assert!(app.pending_selection.is_some(), "held until the list lands"); + assert!( + app.git.view.pending_selection.is_some(), + "held until the list lands" + ); tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -109,6 +116,9 @@ fn a_saved_selection_is_restored_by_the_first_snapshot() { .unwrap(); app.poll_snapshot(); - assert_eq!(app.status_view.files[app.status_view.selected].path, "b.rs"); - assert!(app.pending_selection.is_none(), "consumed"); + assert_eq!( + app.git.view.status.files[app.git.view.status.selected].path, + "b.rs" + ); + assert!(app.git.view.pending_selection.is_none(), "consumed"); } diff --git a/src/app/tests/snapshot_refresh.rs b/src/app/tests/snapshot_refresh.rs index 8e46c90b..40ce76cc 100644 --- a/src/app/tests/snapshot_refresh.rs +++ b/src/app/tests/snapshot_refresh.rs @@ -3,12 +3,9 @@ use super::*; #[test] fn successful_snapshot_preserves_terminal_status() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - notice: Some(Notice::new(NoticeKind::Terminal, "backend unavailable")), - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.notice = Some(Notice::new(NoticeKind::Terminal, "backend unavailable")); tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -32,12 +29,9 @@ fn successful_snapshot_preserves_terminal_status() { #[test] fn successful_snapshot_clears_git_status() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - notice: Some(Notice::new(NoticeKind::Git, "not a repo")), - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; + let mut app = app_with_files(vec![]); + app.git.snapshot = snapshot; + app.notice = Some(Notice::new(NoticeKind::Git, "not a repo")); tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -58,13 +52,10 @@ fn successful_snapshot_clears_git_status() { #[test] fn snapshot_refresh_clamps_selection_to_active_filter() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); + let mut app = app_with_files(vec!["bar.rs"]); + app.git.snapshot = snapshot; + app.git.view.status.search_query.set("bar"); + app.git.view.status.recompute_filter(); tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -83,9 +74,9 @@ fn snapshot_refresh_clamps_selection_to_active_filter() { app.poll_snapshot(); assert_eq!(app.filtered_indices(), &[1]); - assert_eq!(app.status_view.selected, 1); + assert_eq!(app.git.view.status.selected, 1); assert_eq!( - app.status_view.files[app.status_view.selected].path, + app.git.view.status.files[app.git.view.status.selected].path, "bar2.rs" ); } @@ -93,11 +84,8 @@ fn snapshot_refresh_clamps_selection_to_active_filter() { #[test] fn snapshot_invalidates_path_width_cache_on_same_length_rename() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["short.rs"]) - }; + let mut app = app_with_files(vec!["short.rs"]); + app.git.snapshot = snapshot; // Prime the width cache by reading the right-scroll bound once. app.file_scroll_right(); // Rename to a longer path while keeping the file count constant. @@ -123,20 +111,17 @@ fn snapshot_invalidates_path_width_cache_on_same_length_rename() { for _ in 0..20 { app.file_scroll_right(); } - assert!(app.status_view.file_scroll_x >= "short.rs".chars().count()); + assert!(app.git.view.status.file_scroll_x >= "short.rs".chars().count()); } #[test] fn snapshot_refresh_with_no_filter_matches_clears_stale_diff() { let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); - app.diff.hunks = vec![context_hunk(&["stale"])]; + let mut app = app_with_files(vec!["bar.rs"]); + app.git.snapshot = snapshot; + app.git.view.status.search_query.set("bar"); + app.git.view.status.recompute_filter(); + app.git.view.diff.set_hunks(vec![context_hunk(&["stale"])]); tx.send(SnapshotMsg::Ok( RepoSnapshot { @@ -155,5 +140,52 @@ fn snapshot_refresh_with_no_filter_matches_clears_stale_diff() { app.poll_snapshot(); assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); + assert!(app.git.view.diff.hunks().is_empty()); +} + +#[test] +fn non_selected_file_change_does_not_reload_the_selected_diff() { + let mut app = app_with_files(vec!["selected.rs", "other.rs"]); + app.git.repo_path = "missing-repo-used-to-detect-unwanted-load".to_string(); + app.git + .view + .diff + .set_hunks(vec![context_hunk(&["selected diff"])]); + let selected_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + app.git + .view + .status + .hot_table + .insert("selected.rs".to_string(), selected_mtime); + + app.ingest_snapshot( + RepoSnapshot { + files: vec![ + ChangedFile::unstaged_only("selected.rs".to_string(), StatusKind::Modified), + ChangedFile::unstaged_only("other.rs".to_string(), StatusKind::Modified), + ], + tracking: None, + head_oid: None, + branch_name: None, + refs_fingerprint: 0, + }, + HashMap::from([ + ("selected.rs".to_string(), selected_mtime), + ( + "other.rs".to_string(), + SystemTime::UNIX_EPOCH + Duration::from_secs(20), + ), + ]), + ); + + assert_eq!( + app.git.view.diff.hunks()[0].lines[0].content, + "selected diff" + ); + assert!( + app.notice + .as_ref() + .is_none_or(|notice| notice.kind != NoticeKind::Diff), + "an unchanged selection must not start a repository load" + ); } diff --git a/src/app/tests/status_diff.rs b/src/app/tests/status_diff.rs index 28de6d5c..d7935246 100644 --- a/src/app/tests/status_diff.rs +++ b/src/app/tests/status_diff.rs @@ -3,8 +3,8 @@ use super::*; #[test] fn selection_clamps_when_file_list_shrinks() { let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); - app.status_view.selected = 2; - app.status_view.files = vec![ChangedFile::unstaged_only( + app.git.view.status.selected = 2; + app.git.view.status.files = vec![ChangedFile::unstaged_only( "a.rs".to_string(), StatusKind::Modified, )]; @@ -12,14 +12,14 @@ fn selection_clamps_when_file_list_shrinks() { let selected_path = app.restore_selection(Some("c.rs")); assert_eq!(selected_path.as_deref(), Some("a.rs")); - assert_eq!(app.status_view.selected, 0); + assert_eq!(app.git.view.status.selected, 0); } #[test] fn selection_prefers_same_path_after_refresh() { let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); - app.status_view.selected = 1; - app.status_view.files = vec![ + app.git.view.status.selected = 1; + app.git.view.status.files = vec![ ChangedFile::unstaged_only("a.rs".to_string(), StatusKind::Modified), ChangedFile::unstaged_only("c.rs".to_string(), StatusKind::Modified), ChangedFile::unstaged_only("b.rs".to_string(), StatusKind::Modified), @@ -28,18 +28,18 @@ fn selection_prefers_same_path_after_refresh() { let selected_path = app.restore_selection(Some("b.rs")); assert_eq!(selected_path.as_deref(), Some("b.rs")); - assert_eq!(app.status_view.selected, 2); + assert_eq!(app.git.view.status.selected, 2); } #[test] fn diff_scroll_saturates_on_page_up() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.scroll = 3; + app.git.view.diff.scroll = 3; app.page_up(); - assert_eq!(app.diff.scroll, 0); + assert_eq!(app.git.view.diff.scroll, 0); } #[test] @@ -47,69 +47,75 @@ fn diff_scroll_clamps_at_last_line_on_select_down() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; // 1 hunk = header + 1 content line = 2 total lines, max_scroll = 1 - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = 1; // already at max + app.git.view.diff.set_hunks(vec![context_hunk(&["x"])]); + app.git.view.diff.scroll = 1; // already at max app.select_down(); - assert_eq!(app.diff.scroll, 1, "scroll must not exceed last line index"); + assert_eq!( + app.git.view.diff.scroll, 1, + "scroll must not exceed last line index" + ); } #[test] fn diff_scroll_clamps_at_last_line_on_page_down() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = 0; + app.git.view.diff.set_hunks(vec![context_hunk(&["x"])]); + app.git.view.diff.scroll = 0; app.page_down(); // +20, but max is 1 - assert_eq!(app.diff.scroll, 1); + assert_eq!(app.git.view.diff.scroll, 1); } #[test] fn diff_scroll_handles_large_restored_offset() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = usize::MAX; + app.git.view.diff.set_hunks(vec![context_hunk(&["x"])]); + app.git.view.diff.scroll = usize::MAX; app.select_down(); - assert_eq!(app.diff.scroll, 1); + assert_eq!(app.git.view.diff.scroll, 1); } #[test] fn diff_match_refresh_can_preserve_manual_scroll() { let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["needle"])]; - app.diff.search.query.set("needle"); - app.diff.scroll = 7; + app.git.view.diff.set_hunks(vec![context_hunk(&["needle"])]); + app.git.view.diff.search.query.set("needle"); + app.git.view.diff.scroll = 7; - app.diff.recompute_matches(false); + app.git.view.diff.recompute_matches(false); - assert_eq!(app.diff.search.matches, vec![1]); - assert_eq!(app.diff.scroll, 7); + assert_eq!(app.git.view.diff.search.matches, vec![1]); + assert_eq!(app.git.view.diff.scroll, 7); } #[test] fn diff_search_input_scrolls_to_first_match() { let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["alpha", "needle"])]; + app.git + .view + .diff + .set_hunks(vec![context_hunk(&["alpha", "needle"])]); - app.diff.search_push('n'); + app.git.view.diff.search_push('n'); - assert_eq!(app.diff.search.matches, vec![2]); - assert_eq!(app.diff.scroll, 2); + assert_eq!(app.git.view.diff.search.matches, vec![2]); + assert_eq!(app.git.view.diff.scroll, 2); } #[test] fn status_search_with_no_matches_clears_stale_diff() { let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["stale"])]; + app.git.view.diff.set_hunks(vec![context_hunk(&["stale"])]); app.search_push('z'); assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); + assert!(app.git.view.diff.hunks().is_empty()); } diff --git a/src/app/tests/tree.rs b/src/app/tests/tree.rs index c5e16a7b..b3270529 100644 --- a/src/app/tests/tree.rs +++ b/src/app/tests/tree.rs @@ -13,12 +13,14 @@ pub(crate) fn make_tree_repo() -> (tempfile::TempDir, String) { pub(crate) fn app_on(path: &str) -> App { let mut app = app_with_files(vec![]); - app.repo_path = path.to_string(); + app.git.repo_path = path.to_string(); app } pub(crate) fn tree_index_of(app: &App, path: &str) -> usize { - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .position(|r| r.path == path) @@ -32,14 +34,14 @@ fn enter_tree_mode_loads_root_and_shows_file_overlay() { app.enter_tree_mode(); - assert_eq!(app.mode, ViewMode::Tree); - let rows = app.tree_view.visible_rows(); + assert_eq!(app.git.view.mode, ViewMode::Tree); + let rows = app.git.view.tree.visible_rows(); // Directories sort first: src/ before README.md. assert_eq!(rows[0].path, "src"); assert!(rows[0].is_dir); assert!(rows.iter().any(|r| r.path == "README.md")); // The right pane is always the file overlay in Tree mode. - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); drop(dir); } @@ -49,10 +51,12 @@ fn tree_expand_reveals_children_and_collapse_hides_them() { let mut app = app_on(&path); app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); + app.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_expand(); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "src/main.rs"), @@ -60,10 +64,12 @@ fn tree_expand_reveals_children_and_collapse_hides_them() { ); // Cursor is back on the (still-selected) src row; collapsing hides it. - app.tree_view.selected = tree_index_of(&app, "src"); + app.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_collapse(); assert!( - !app.tree_view + !app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "src/main.rs"), @@ -78,15 +84,16 @@ fn selecting_tree_file_loads_raw_contents_into_file_view() { let mut app = app_on(&path); app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "README.md"); + app.git.view.tree.selected = tree_index_of(&app, "README.md"); app.preview_tree_selected(); + app.flush_git_loads_for_test(Duration::from_secs(2)); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); assert_eq!( - app.diff.file_view.key, + app.git.view.diff.file_view.key, Some(FileViewKey::Status("README.md".to_string())) ); - assert_eq!(app.diff.file_view.content, "# hi\n"); + assert_eq!(app.git.view.diff.file_view.content, "# hi\n"); drop(dir); } @@ -95,15 +102,15 @@ fn tree_collapse_on_expanded_child_steps_to_parent() { 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.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_expand(); // Sit on the child file, then collapse: the cursor walks up to `src`. - app.tree_view.selected = tree_index_of(&app, "src/main.rs"); + app.git.view.tree.selected = tree_index_of(&app, "src/main.rs"); app.tree_collapse(); assert_eq!( - app.tree_view.selected_path().as_deref(), + app.git.view.tree.selected_path().as_deref(), Some("src"), "Left on a child should move selection to its parent dir" ); @@ -116,7 +123,7 @@ fn tree_search_finds_file_in_unexpanded_dir() { let mut app = app_on(&path); app.enter_tree_mode(); // `src` starts collapsed, so its child is not in the normal view. - assert!(!app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("src")); app.start_tree_search(); for ch in "main".chars() { @@ -125,17 +132,17 @@ fn tree_search_finds_file_in_unexpanded_dir() { // The match is revealed through its ancestor chain even though `src` // was never manually expanded. - let rows = app.tree_view.visible_rows(); + let rows = app.git.view.tree.visible_rows(); assert!(rows.iter().any(|r| r.path == "src/main.rs")); assert!(rows.iter().any(|r| r.path == "src")); assert!(!rows.iter().any(|r| r.path == "README.md")); // Cursor lands on the matching file, not the connecting `src` dir. assert_eq!( - app.tree_view.selected_path().as_deref(), + app.git.view.tree.selected_path().as_deref(), Some("src/main.rs") ); // Filtering must not mutate the real expansion set. - assert!(!app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("src")); drop(dir); } @@ -153,11 +160,11 @@ fn confirm_tree_search_reveals_match_in_normal_view() { // Overlay closed, query cleared, and `src` is now genuinely expanded so // the chosen file stays visible with the cursor on it. - assert!(!app.tree_view.search_active); - assert!(app.tree_view.search_query.is_empty()); - assert!(app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.search_active); + assert!(app.git.view.tree.search_query.is_empty()); + assert!(app.git.view.tree.expanded.contains("src")); assert_eq!( - app.tree_view.selected_path().as_deref(), + app.git.view.tree.selected_path().as_deref(), Some("src/main.rs") ); drop(dir); @@ -175,12 +182,14 @@ fn cancel_tree_search_leaves_expansion_untouched() { } app.cancel_tree_search(); - assert!(!app.tree_view.search_active); - assert!(app.tree_view.search_query.is_empty()); + assert!(!app.git.view.tree.search_active); + assert!(app.git.view.tree.search_query.is_empty()); // Esc must not expand anything; the view returns to its prior state. - assert!(!app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("src")); assert!( - !app.tree_view + !app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "src/main.rs") @@ -192,13 +201,13 @@ fn cancel_tree_search_leaves_expansion_untouched() { fn toggle_tree_mode_round_trips_status_and_tree() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); app.toggle_tree_mode(); - assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.git.view.mode, ViewMode::Tree); app.toggle_tree_mode(); - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); drop(dir); } @@ -209,7 +218,9 @@ fn enter_tree_mode_picks_up_dir_created_after_first_entry() { // First entry caches the root listing (no `docs/` yet). app.enter_tree_mode(); assert!( - !app.tree_view + !app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "docs"), @@ -224,7 +235,9 @@ fn enter_tree_mode_picks_up_dir_created_after_first_entry() { // Re-entering must re-read the root and surface the new directory. app.enter_tree_mode(); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "docs"), @@ -239,16 +252,16 @@ fn enter_tree_mode_reflects_moved_dir_without_error() { let mut app = app_on(&path); // Cache the root with `src/` present, expanded. app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); + app.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_expand(); - assert!(app.tree_view.expanded.contains("src")); + assert!(app.git.view.tree.expanded.contains("src")); // Move `src/` to `lib/` on disk while away from Tree mode. app.exit_tree_to_status(); std::fs::rename(Path::new(&path).join("src"), Path::new(&path).join("lib")).unwrap(); app.enter_tree_mode(); - let rows = app.tree_view.visible_rows(); + let rows = app.git.view.tree.visible_rows(); assert!( !rows.iter().any(|r| r.path == "src"), "the moved-away directory must disappear from its old location" @@ -259,7 +272,7 @@ fn enter_tree_mode_reflects_moved_dir_without_error() { ); // The stale `src` expansion is pruned (it no longer exists), so no // failing re-read leaks a "tree error" into the status bar. - assert!(!app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("src")); assert!( !app.notice .as_ref() diff --git a/src/app/tests/tree_open.rs b/src/app/tests/tree_open.rs index c97011cf..2014237c 100644 --- a/src/app/tests/tree_open.rs +++ b/src/app/tests/tree_open.rs @@ -8,24 +8,24 @@ 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.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_open_selected(); assert!( - !app.tree_view.expanded.contains("src"), + !app.git.view.tree.expanded.contains("src"), "Enter must not expand a directory" ); assert!( - !app.diff.fullscreen, + !app.git.view.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.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_open_selected(); assert!( - app.tree_view.expanded.contains("src"), + app.git.view.tree.expanded.contains("src"), "Enter must not collapse a directory" ); drop(dir); @@ -36,17 +36,18 @@ 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.git.view.tree.selected = tree_index_of(&app, "README.md"); app.tree_open_selected(); + app.flush_git_loads_for_test(Duration::from_secs(2)); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); assert_eq!( - app.diff.file_view.key, + app.git.view.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.git.view.diff.file_view.content, "# hi\n"); + assert!(app.git.view.diff.fullscreen); assert_eq!(app.focus, Focus::DiffViewer); drop(dir); } @@ -58,11 +59,11 @@ fn tree_open_on_file_row_clears_competing_fullscreens() { app.enter_tree_mode(); app.list_fullscreen = true; app.terminal.fullscreen = TerminalFullscreen::Grid; - app.tree_view.selected = tree_index_of(&app, "README.md"); + app.git.view.tree.selected = tree_index_of(&app, "README.md"); app.tree_open_selected(); - assert!(app.diff.fullscreen); + assert!(app.git.view.diff.fullscreen); assert!(!app.list_fullscreen); assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); drop(dir); diff --git a/src/app/tests/tree_session.rs b/src/app/tests/tree_session.rs index 85fca4dc..04b98271 100644 --- a/src/app/tests/tree_session.rs +++ b/src/app/tests/tree_session.rs @@ -11,15 +11,15 @@ fn a_change_in_a_collapsed_directory_updates_search_results() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); + app.git.view.tree_watch = TreeWatcher::from_receiver(rx); app.enter_tree_mode(); app.start_tree_search(); for c in "main".chars() { app.tree_search_push(c); } - let before = app.tree_view.match_count; + let before = app.git.view.tree.match_count; assert!( - !app.tree_view.expanded.contains("src"), + !app.git.view.tree.expanded.contains("src"), "src stays collapsed — the point of the test" ); @@ -31,7 +31,7 @@ fn a_change_in_a_collapsed_directory_updates_search_results() { .unwrap(); app.poll_tree_watcher(); - assert_eq!(app.tree_view.match_count, before + 1); + assert_eq!(app.git.view.tree.match_count, before + 1); drop(dir); } @@ -47,13 +47,13 @@ fn a_watcher_refresh_updates_active_search_results() { for c in "main".chars() { app.tree_search_push(c); } - let before = app.tree_view.match_count; + let before = app.git.view.tree.match_count; std::fs::write(Path::new(&path).join("src").join("main_two.rs"), "\n").unwrap(); app.refresh_tree_preserving_cursor(); assert_eq!( - app.tree_view.match_count, + app.git.view.tree.match_count, before + 1, "a file created while the search is open must join the results" ); @@ -66,34 +66,34 @@ fn a_hidden_tree_change_is_remembered_until_the_tab_is_shown() { // records that it must, so filesystem churn elsewhere cannot stall the // active tab. let mut app = app_with_files(vec!["a.rs"]); - app.mode = ViewMode::Tree; - app.tree_dirty.insert("src".to_string()); + app.git.view.mode = ViewMode::Tree; + app.git.view.tree_dirty.insert("src".to_string()); // Draining with no new event leaves the flag standing, so the refresh // still happens once this project becomes the active one. app.drain_tree_watcher(); assert!( - !app.tree_dirty.is_empty(), + !app.git.view.tree_dirty.is_empty(), "a pending refresh survives a drain" ); app.poll_tree_watcher(); - assert!(app.tree_dirty.is_empty(), "the active project consumes it"); + assert!( + app.git.view.tree_dirty.is_empty(), + "the active project consumes it" + ); } #[test] fn tree_preview_survives_status_snapshot() { let (dir, path) = make_tree_repo(); let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_on(&path) - }; + let mut app = app_on(&path); + app.git.snapshot = snapshot; app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "README.md"); + app.git.view.tree.selected = tree_index_of(&app, "README.md"); app.preview_tree_selected(); - let content_before = app.diff.file_view.content.clone(); + let content_before = app.git.view.diff.file_view.content.clone(); // A git-status snapshot arrives (e.g. file changed in a terminal pane). tx.send(SnapshotMsg::Ok( @@ -110,9 +110,9 @@ fn tree_preview_survives_status_snapshot() { app.poll_snapshot(); // Tree mode and its preview must be untouched by the snapshot ingest. - assert_eq!(app.mode, ViewMode::Tree); - assert_eq!(app.diff.view, DiffPaneView::File); - assert_eq!(app.diff.file_view.content, content_before); + assert_eq!(app.git.view.mode, ViewMode::Tree); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.file_view.content, content_before); drop(dir); } @@ -126,19 +126,19 @@ fn restoring_tree_session_clears_lingering_status_search() { let mut app = app_on(&path); app.start_search(); app.search_push('x'); - assert!(app.status_view.search_active); + assert!(app.git.view.status.search_active); app.restore_session(&crate::workspace::persistence::SessionState { mode: Some(ViewMode::Tree), ..Default::default() }); - assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.git.view.mode, ViewMode::Tree); assert!( - !app.status_view.search_active, + !app.git.view.status.search_active, "restoring Tree mode must clear a lingering status search overlay" ); - assert!(app.status_view.search_query.is_empty()); + assert!(app.git.view.status.search_query.is_empty()); drop(dir); } @@ -148,12 +148,12 @@ fn entering_tree_mode_clears_lingering_status_search() { let mut app = app_on(&path); app.start_search(); app.search_push('x'); - assert!(app.status_view.search_active); + assert!(app.git.view.status.search_active); app.enter_tree_mode(); - assert!(!app.status_view.search_active); - assert!(app.status_view.search_query.is_empty()); + assert!(!app.git.view.status.search_active); + assert!(app.git.view.status.search_query.is_empty()); drop(dir); } @@ -174,12 +174,12 @@ fn restore_tree_session_ignores_unsafe_expanded_paths() { ..Default::default() }); - assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.git.view.mode, ViewMode::Tree); // Only the safe, real directory was expanded/cached. - assert!(app.tree_view.expanded.contains("src")); - assert!(!app.tree_view.expanded.contains("../../..")); - assert!(!app.tree_view.expanded.contains("/etc")); - assert!(!app.tree_view.cache.contains_key("/etc")); + assert!(app.git.view.tree.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("../../..")); + assert!(!app.git.view.tree.expanded.contains("/etc")); + assert!(!app.git.view.tree.cache.contains_key("/etc")); drop(dir); } @@ -199,11 +199,18 @@ fn restore_tree_session_prunes_expansion_gone_since_save() { ..Default::default() }); - assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.git.view.mode, ViewMode::Tree); // `src` no longer exists on disk, so it must not be kept as expanded... - assert!(!app.tree_view.expanded.contains("src")); + assert!(!app.git.view.tree.expanded.contains("src")); // ...and the moved-to directory is visible at the root. - assert!(app.tree_view.visible_rows().iter().any(|r| r.path == "lib")); + assert!( + app.git + .view + .tree + .visible_rows() + .iter() + .any(|r| r.path == "lib") + ); assert!( !app.notice .as_ref() @@ -221,12 +228,12 @@ fn entering_tree_cancels_in_flight_commit_log_fetch() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); app.spawn_commit_log_refresh_fetch(None, None); - assert!(app.pagination.page_rx.is_some(), "fetch should be pending"); + assert!(app.commit_log_fetch_pending(), "fetch should be pending"); app.enter_tree_mode(); assert!( - app.pagination.page_rx.is_none(), + !app.commit_log_fetch_pending(), "entering Tree mode must cancel the in-flight commit-log fetch" ); drop(dir); @@ -237,13 +244,13 @@ fn tree_mode_diff_file_and_split_toggles_are_noops() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); app.enter_tree_mode(); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); // `v` and `s` must not flip the right pane away from the file preview. app.toggle_diff_file_view(); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.git.view.diff.view, DiffPaneView::File); drop(dir); } @@ -252,9 +259,9 @@ fn tree_session_round_trips_mode_expansion_and_selection() { 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.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_expand(); - app.tree_view.selected = tree_index_of(&app, "src/main.rs"); + app.git.view.tree.selected = tree_index_of(&app, "src/main.rs"); let state = app.save_session(); assert_eq!(state.mode, Some(ViewMode::Tree)); @@ -263,14 +270,15 @@ fn tree_session_round_trips_mode_expansion_and_selection() { let mut other = app_on(&path); other.restore_session(&state); - assert_eq!(other.mode, ViewMode::Tree); - assert!(other.tree_view.expanded.contains("src")); + other.flush_git_loads_for_test(Duration::from_secs(2)); + assert_eq!(other.git.view.mode, ViewMode::Tree); + assert!(other.git.view.tree.expanded.contains("src")); assert_eq!( - other.tree_view.selected_path().as_deref(), + other.git.view.tree.selected_path().as_deref(), Some("src/main.rs") ); // The restored selection previews the file, not a diff. - assert_eq!(other.diff.view, DiffPaneView::File); - assert_eq!(other.diff.file_view.content, "fn main() {}\n"); + assert_eq!(other.git.view.diff.view, DiffPaneView::File); + assert_eq!(other.git.view.diff.file_view.content, "fn main() {}\n"); drop(dir); } diff --git a/src/app/tests/tree_watcher.rs b/src/app/tests/tree_watcher.rs index 1eccf877..31fc8ce4 100644 --- a/src/app/tests/tree_watcher.rs +++ b/src/app/tests/tree_watcher.rs @@ -6,10 +6,12 @@ fn refresh_tree_cache_keeps_expansion_for_surviving_dirs() { 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.git.view.tree.selected = tree_index_of(&app, "src"); app.tree_expand(); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "src/main.rs"), @@ -18,9 +20,11 @@ fn refresh_tree_cache_keeps_expansion_for_surviving_dirs() { app.refresh_tree_cache(); - assert!(app.tree_view.expanded.contains("src")); + assert!(app.git.view.tree.expanded.contains("src")); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "src/main.rs"), @@ -35,7 +39,7 @@ fn enter_tree_mode_keeps_cursor_on_same_path_when_rows_shift() { let mut app = app_on(&path); app.enter_tree_mode(); // Park the cursor on README.md. - app.tree_view.selected = tree_index_of(&app, "README.md"); + app.git.view.tree.selected = tree_index_of(&app, "README.md"); // Insert a directory that sorts ahead of everything, shifting README.md // down by one row. @@ -46,7 +50,7 @@ fn enter_tree_mode_keeps_cursor_on_same_path_when_rows_shift() { // The cursor must follow README.md, not stay on its old index (which now // points at a different row). assert_eq!( - app.tree_view.selected_path().as_deref(), + app.git.view.tree.selected_path().as_deref(), Some("README.md"), "cursor must track its path across the row-set shift" ); @@ -60,10 +64,12 @@ fn poll_tree_watcher_refreshes_tree_on_event_in_tree_mode() { let mut app = app_on(&path); // Swap in a watcher we can feed synthetic events into. let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); + app.git.view.tree_watch = TreeWatcher::from_receiver(rx); app.enter_tree_mode(); assert!( - !app.tree_view + !app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "docs") @@ -79,7 +85,9 @@ fn poll_tree_watcher_refreshes_tree_on_event_in_tree_mode() { app.poll_tree_watcher(); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "docs"), @@ -94,17 +102,17 @@ fn poll_tree_watcher_ignores_events_outside_tree_mode() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); + app.git.view.tree_watch = TreeWatcher::from_receiver(rx); // Never enter Tree mode. - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); tx.send(Ok(Vec::new())).unwrap(); app.poll_tree_watcher(); // The event is drained but must not build/touch the tree off-screen. - assert_eq!(app.mode, ViewMode::Status); - assert!(app.tree_view.cache.is_empty()); + assert_eq!(app.git.view.mode, ViewMode::Status); + assert!(app.git.view.tree.cache.is_empty()); drop(dir); } @@ -114,14 +122,14 @@ fn leaving_tree_for_log_clears_watches() { let mut app = app_on(&path); app.enter_tree_mode(); assert!( - app.tree_watch.watched_count() > 0, + app.git.view.tree_watch.watched_count() > 0, "entering Tree mode watches at least the root" ); app.toggle_mode(); // Tree -> Log via l - assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.git.view.mode, ViewMode::Log); assert_eq!( - app.tree_watch.watched_count(), + app.git.view.tree_watch.watched_count(), 0, "leaving Tree for Log must drop all watches" ); @@ -133,11 +141,11 @@ fn leaving_tree_for_status_clears_watches() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); app.enter_tree_mode(); - assert!(app.tree_watch.watched_count() > 0); + assert!(app.git.view.tree_watch.watched_count() > 0); app.exit_tree_to_status(); - assert_eq!(app.mode, ViewMode::Status); - assert_eq!(app.tree_watch.watched_count(), 0); + assert_eq!(app.git.view.mode, ViewMode::Status); + assert_eq!(app.git.view.tree_watch.watched_count(), 0); drop(dir); } @@ -149,6 +157,6 @@ fn toggle_mode_from_tree_enters_log_view() { // ` l` from Tree goes to Log (not back to Status). app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.git.view.mode, ViewMode::Log); drop(dir); } diff --git a/src/app/tree.rs b/src/app/tree.rs index 1913fb9e..90e1a0f2 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -1,22 +1,21 @@ //! `App` methods for the read-only file-tree navigator (`ViewMode::Tree`). //! //! Directory I/O is synchronous on the UI thread (one level per expansion); -//! the git-status snapshot worker is never involved. Selecting a file row -//! loads its raw contents into the existing file-view pane. +//! the git-status snapshot worker is never involved. use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; use std::collections::BTreeSet; impl App { pub fn enter_tree_mode(&mut self) { - self.mode = ViewMode::Tree; + self.git.view.set_mode(ViewMode::Tree); // A Log-mode page fetch in flight would clobber the Tree preview a tick // later; cancel so only Tree controls the diff pane while active. self.cancel_commit_log_page_fetch(); // Drop lingering search overlays so their modal handlers can't capture // Tree keystrokes after the mode switch. - self.status_view.cancel_search(); - self.tree_view.cancel_search(); + self.git.view.status.cancel_search(); + self.git.view.tree.cancel_search(); self.clear_diff_state(); // Re-read from disk so structural changes while away from Tree show up // (the per-directory cache is otherwise only cleared on repo switch). @@ -31,24 +30,24 @@ impl App { &mut self, invalidate: Option<&BTreeSet>, ) { - let prev_path = self.tree_view.selected_path(); + let prev_path = self.git.view.tree.selected_path(); self.refresh_tree_cache_scoped(invalidate); // The filtered view renders from the search index, not the cache, so // refreshing only the cache would leave results stale until the query // changed. The rebuild walks the cache (only invalidated listings // re-read), so cost is proportional to what changed. - if self.tree_view.search_active { + if self.git.view.tree.search_active { self.build_tree_index(); - self.tree_view.recompute_filter(); + self.git.view.tree.recompute_filter(); } - let rows = self.tree_view.visible_rows(); + let rows = self.git.view.tree.visible_rows(); if let Some(idx) = prev_path .as_deref() .and_then(|p| rows.iter().position(|r| r.path == p)) { - self.tree_view.selected = idx; + self.git.view.tree.selected = idx; } - self.tree_view.clamp_selection(rows.len()); + self.git.view.tree.clamp_selection(rows.len()); self.preview_tree_selected(); } @@ -62,22 +61,24 @@ impl App { // is a no-op for a listing still in the cache. pub(crate) fn refresh_tree_cache_scoped(&mut self, invalidate: Option<&BTreeSet>) { match invalidate { - None => self.tree_view.cache.clear(), + None => self.git.view.tree.cache.clear(), Some(dirs) => { for dir in dirs { - self.tree_view.cache.remove(dir); + self.git.view.tree.cache.remove(dir); } } } self.ensure_tree_root(); - let mut dirs: Vec = self.tree_view.expanded.iter().cloned().collect(); + let mut dirs: Vec = self.git.view.tree.expanded.iter().cloned().collect(); dirs.sort_by_key(|p| p.matches('/').count()); let mut kept = BTreeSet::new(); for dir in dirs { let parent = crate::ui::tree_view::parent_path(&dir).unwrap_or(""); let name = dir.rsplit('/').next().unwrap_or(&dir); let still_a_dir = self - .tree_view + .git + .view + .tree .cache .get(parent) .is_some_and(|children| children.iter().any(|e| e.is_dir && e.name == name)); @@ -86,33 +87,33 @@ impl App { kept.insert(dir); } } - self.tree_view.expanded = kept; - self.tree_view.row_width_cache.set(None); + self.git.view.tree.expanded = kept; + self.git.view.tree.row_width_cache.set(None); self.sync_tree_watches(); } pub(crate) fn sync_tree_watches(&mut self) { - if !self.cfg_tree.live_watch { + if !self.git.tree_config.live_watch { return; } // A filename search matches the whole tree, not just what is expanded, // so a file created in a collapsed directory must produce an event. - let mut desired: BTreeSet = if self.tree_view.search_active { - self.tree_view.cache.keys().cloned().collect() + let mut desired: BTreeSet = if self.git.view.tree.search_active { + self.git.view.tree.cache.keys().cloned().collect() } else { - self.tree_view.expanded.iter().cloned().collect() + self.git.view.tree.expanded.iter().cloned().collect() }; // Root is always watched so top-level creations/removals are caught // even with nothing expanded. desired.insert(String::new()); if let Some(workdir) = self.tree_workdir() { - self.tree_watch.sync(&workdir, &desired); + self.git.view.tree_watch.sync(&workdir, &desired); } } pub(crate) fn clear_tree_watches(&mut self) { if let Some(workdir) = self.tree_workdir() { - self.tree_watch.sync(&workdir, &BTreeSet::new()); + self.git.view.tree_watch.sync(&workdir, &BTreeSet::new()); } } @@ -125,35 +126,39 @@ impl App { // Cheap half: no directory reread, no preview. Every project runs this each // tick so OS events can't pile up behind a hidden tab; rereading waits for // that tab to come forward. - pub fn drain_tree_watcher(&mut self) { - let changes = self.tree_watch.drain_changed(); + pub fn drain_tree_watcher(&mut self) -> bool { + let changes = self.git.view.tree_watch.drain_changed(); if changes.is_empty() { - return; + return false; } if changes.unknown { // Events may have been dropped — no directory set can be trusted // complete; fall back to re-reading everything. - self.tree_dirty_all = true; + self.git.view.tree_dirty_all = true; } - self.tree_dirty.extend(changes.dirs); + self.git.view.tree_dirty.extend(changes.dirs); + true } // Only the project on screen does this — several repos rereading per tick // would stall the active tab. - pub fn poll_tree_watcher(&mut self) { + pub fn poll_tree_watcher(&mut self) -> bool { self.drain_tree_watcher(); - if self.mode != ViewMode::Tree || (self.tree_dirty.is_empty() && !self.tree_dirty_all) { - return; + if self.git.view.mode != ViewMode::Tree + || (self.git.view.tree_dirty.is_empty() && !self.git.view.tree_dirty_all) + { + return false; } - let all = std::mem::take(&mut self.tree_dirty_all); - let dirs = std::mem::take(&mut self.tree_dirty); + let all = std::mem::take(&mut self.git.view.tree_dirty_all); + let dirs = std::mem::take(&mut self.git.view.tree_dirty); self.refresh_tree_preserving_cursor_scoped(if all { None } else { Some(&dirs) }); + true } pub fn exit_tree_to_status(&mut self) { - self.tree_view.cancel_search(); + self.git.view.tree.cancel_search(); self.clear_tree_watches(); - self.mode = ViewMode::Status; + self.git.view.set_mode(ViewMode::Status); self.clear_diff_state(); self.refresh_diff(true); } @@ -165,10 +170,10 @@ impl App { // A read error caches an empty listing and surfaces the message so a // single unreadable directory can't wedge navigation. pub(crate) fn ensure_tree_children(&mut self, dir: &str) { - if self.tree_view.cache.contains_key(dir) { + if self.git.view.tree.cache.contains_key(dir) { return; } - let respect = self.cfg_tree.respect_gitignore; + let respect = self.git.tree_config.respect_gitignore; let dir_owned = dir.to_string(); let result = self.with_repo(|repo| { let workdir = repo @@ -181,32 +186,32 @@ impl App { // A successful read resolves whatever the last failing one // reported; without this the tree error outlived its cause. self.clear_notice(NoticeKind::Tree); - self.tree_view.cache.insert(dir.to_string(), children); + self.git.view.tree.cache.insert(dir.to_string(), children); } Err(e) => { tracing::warn!(error = %e, dir = %dir, "failed to read tree directory"); self.raise_notice(NoticeKind::Tree, e.to_string()); // Cache empty so we don't retry the failing read on every // keystroke; a repo change / refresh clears the cache. - self.tree_view.cache.insert(dir.to_string(), Vec::new()); + self.git.view.tree.cache.insert(dir.to_string(), Vec::new()); } } } pub(crate) fn preview_tree_selected(&mut self) { - let selected = self.tree_view.selected; - let row = self.tree_view.visible_rows().into_iter().nth(selected); + let selected = self.git.view.tree.selected; + let row = self.git.view.tree.visible_rows().into_iter().nth(selected); match row { Some(row) if !row.is_dir => { let key = FileViewKey::Status(row.path); - if self.diff.file_view.key.as_ref() != Some(&key) { + if self.git.view.diff.file_view.key.as_ref() != Some(&key) { self.load_file_view(key); } - self.diff.view = DiffPaneView::File; + self.git.view.diff.view = DiffPaneView::File; } _ => { - self.diff.view = DiffPaneView::File; - self.diff.file_view = FileViewState::default(); + self.git.view.diff.view = DiffPaneView::File; + self.git.view.diff.file_view = FileViewState::default(); } } } diff --git a/src/app/tree_nav.rs b/src/app/tree_nav.rs index ac120f72..0e0d6cd2 100644 --- a/src/app/tree_nav.rs +++ b/src/app/tree_nav.rs @@ -3,17 +3,17 @@ use crate::ui::tree_view::{TreeIndexEntry, parent_path}; impl App { fn move_tree_selection(&mut self, delta: isize) { - let len = self.tree_view.visible_rows().len(); + let len = self.git.view.tree.visible_rows().len(); if len == 0 { - self.tree_view.selected = 0; + self.git.view.tree.selected = 0; return; } let last = len as isize - 1; - let current = self.tree_view.selected.min(len - 1) as isize; + let current = self.git.view.tree.selected.min(len - 1) as isize; let new = (current + delta).clamp(0, last) as usize; - if new != self.tree_view.selected { - self.tree_view.selected = new; - self.tree_view.scroll_x = 0; + if new != self.git.view.tree.selected { + self.git.view.tree.selected = new; + self.git.view.tree.scroll_x = 0; self.preview_tree_selected(); } } @@ -35,20 +35,20 @@ impl App { } pub fn tree_expand(&mut self) { - let selected = self.tree_view.selected; - let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { + let selected = self.git.view.tree.selected; + let Some(row) = self.git.view.tree.visible_rows().into_iter().nth(selected) else { return; }; - if !row.is_dir || self.tree_view.expanded.contains(&row.path) { + if !row.is_dir || self.git.view.tree.expanded.contains(&row.path) { return; } - if row.depth + 1 > self.cfg_tree.max_depth { + if row.depth + 1 > self.git.tree_config.max_depth { return; } self.ensure_tree_children(&row.path); - self.tree_view.expanded.insert(row.path); + self.git.view.tree.expanded.insert(row.path); // Visible rows changed: drop a stale horizontal-scroll width bound. - self.tree_view.row_width_cache.set(None); + self.git.view.tree.row_width_cache.set(None); // A newly expanded directory becomes visible — start watching it. self.sync_tree_watches(); } @@ -56,20 +56,22 @@ impl App { // Collapse the selected directory if expanded; otherwise move the cursor // up to its parent directory row (so repeated `Left` walks back out). pub fn tree_collapse(&mut self) { - let rows = self.tree_view.visible_rows(); - let Some(row) = rows.get(self.tree_view.selected) else { + let rows = self.git.view.tree.visible_rows(); + let Some(row) = rows.get(self.git.view.tree.selected) else { return; }; - if row.is_dir && self.tree_view.expanded.contains(&row.path) { + if row.is_dir && self.git.view.tree.expanded.contains(&row.path) { let path = row.path.clone(); // Drop the directory and every descendant so re-expanding later // starts collapsed rather than restoring a deep subtree the user // explicitly closed. let prefix = format!("{path}/"); - self.tree_view + self.git + .view + .tree .expanded .retain(|p| p != &path && !p.starts_with(&prefix)); - self.tree_view.row_width_cache.set(None); + self.git.view.tree.row_width_cache.set(None); // The collapsed subtree is no longer visible — stop watching it. self.sync_tree_watches(); return; @@ -77,8 +79,8 @@ impl App { if let Some(parent) = parent_path(&row.path) { let parent = parent.to_string(); if let Some(idx) = rows.iter().position(|r| r.path == parent) { - self.tree_view.selected = idx; - self.tree_view.scroll_x = 0; + self.git.view.tree.selected = idx; + self.git.view.tree.scroll_x = 0; self.preview_tree_selected(); } } @@ -88,8 +90,8 @@ impl App { // 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 { + let selected = self.git.view.tree.selected; + let Some(row) = self.git.view.tree.visible_rows().into_iter().nth(selected) else { return; }; if row.is_dir { @@ -103,9 +105,9 @@ impl App { // the (still unfiltered) view until the user types a query. pub fn start_tree_search(&mut self) { self.build_tree_index(); - self.tree_view.search_active = true; - self.tree_view.search_query.clear(); - self.tree_view.recompute_filter(); + self.git.view.tree.search_active = true; + self.git.view.tree.search_query.clear(); + self.git.view.tree.recompute_filter(); // The results now span the whole tree, so the watch set has to as well // — a file created in a directory the user never expanded still // changes them. `sync_tree_watches` reads `search_active`, so this @@ -114,27 +116,27 @@ impl App { } pub fn tree_search_push(&mut self, ch: char) { - self.tree_view.search_query.push(ch); - self.tree_view.recompute_filter(); + self.git.view.tree.search_query.push(ch); + self.git.view.tree.recompute_filter(); self.reset_tree_selection_after_filter(); } pub fn tree_search_pop(&mut self) { - self.tree_view.search_query.pop(); - self.tree_view.recompute_filter(); + self.git.view.tree.search_query.pop(); + self.git.view.tree.recompute_filter(); self.reset_tree_selection_after_filter(); } // Close the overlay without changing the expansion state; the cursor stays // on whatever row maps into the now-unfiltered view. pub fn cancel_tree_search(&mut self) { - self.tree_view.cancel_search(); + self.git.view.tree.cancel_search(); // Back to watching only what is expanded: the wider set existed for // the filtered view and would otherwise hold descriptors for the whole // tree until Tree mode was left. self.sync_tree_watches(); - let row_count = self.tree_view.visible_rows().len(); - self.tree_view.clamp_selection(row_count); + let row_count = self.git.view.tree.visible_rows().len(); + self.git.view.tree.clamp_selection(row_count); self.preview_tree_selected(); } @@ -142,32 +144,32 @@ impl App { // expanding all of its ancestor directories, close the overlay, and move // the cursor onto that path. An empty query collapses to a cancel. pub fn confirm_tree_search(&mut self) { - if self.tree_view.search_query.is_empty() { + if self.git.view.tree.search_query.is_empty() { self.cancel_tree_search(); return; } - let target = self.tree_view.selected_path(); + let target = self.git.view.tree.selected_path(); if let Some(path) = &target { // Expand every ancestor so the chosen path is visible once // filtering ends. The path itself (if a directory) is left // collapsed — the user opens it explicitly. let mut p = parent_path(path); while let Some(parent) = p { - self.tree_view.expanded.insert(parent.to_string()); + self.git.view.tree.expanded.insert(parent.to_string()); p = parent_path(parent); } } - self.tree_view.cancel_search(); + self.git.view.tree.cancel_search(); self.sync_tree_watches(); if let Some(path) = target { - let rows = self.tree_view.visible_rows(); + let rows = self.git.view.tree.visible_rows(); if let Some(idx) = rows.iter().position(|r| r.path == path) { - self.tree_view.selected = idx; + self.git.view.tree.selected = idx; } } - self.tree_view.scroll_x = 0; - let row_count = self.tree_view.visible_rows().len(); - self.tree_view.clamp_selection(row_count); + self.git.view.tree.scroll_x = 0; + let row_count = self.git.view.tree.visible_rows().len(); + self.git.view.tree.clamp_selection(row_count); self.preview_tree_selected(); } @@ -175,19 +177,19 @@ impl App { // *matching* row (skipping ancestor directories pulled in only to connect // the path). Falls back to the first row when nothing matches directly. fn reset_tree_selection_after_filter(&mut self) { - self.tree_view.scroll_x = 0; - let rows = self.tree_view.visible_rows(); + self.git.view.tree.scroll_x = 0; + let rows = self.git.view.tree.visible_rows(); if rows.is_empty() { - self.tree_view.selected = 0; + self.git.view.tree.selected = 0; self.preview_tree_selected(); return; } - let q = self.tree_view.search_query.lower(); + let q = self.git.view.tree.search_query.lower(); let first_match = rows .iter() .position(|r| r.name.to_lowercase().contains(q)) .unwrap_or(0); - self.tree_view.selected = first_match; + self.git.view.tree.selected = first_match; self.preview_tree_selected(); } @@ -195,13 +197,13 @@ impl App { // triggers it, then all filtering is in-memory. pub(crate) fn build_tree_index(&mut self) { self.ensure_tree_root(); - let max_depth = self.cfg_tree.max_depth; + let max_depth = self.git.tree_config.max_depth; let mut index = Vec::new(); // (dir, depth-of-its-children): the root's children sit at depth 0. let mut stack = vec![(String::new(), 0usize)]; while let Some((dir, depth)) = stack.pop() { self.ensure_tree_children(&dir); - let children = match self.tree_view.cache.get(&dir) { + let children = match self.git.view.tree.cache.get(&dir) { Some(c) => c.clone(), None => continue, }; @@ -222,6 +224,6 @@ impl App { } } } - self.tree_view.index = index; + self.git.view.tree.index = index; } } diff --git a/src/application/attach.rs b/src/application/attach.rs index 48a2509a..f11bba9f 100644 --- a/src/application/attach.rs +++ b/src/application/attach.rs @@ -55,17 +55,15 @@ pub(crate) fn run_attach() -> Result<()> { ws.set_remembered(stored.sessions); } - // Read from the session's file, not asked of the daemon. The set that - // carries the accent is sent by the watcher now, which does not race the - // handshake to get there first — and this screen draws before `main_loop`, - // the only thing that drains the connection. `[theme]` names what a session - // with no stored colour starts in. + // Read from the session's file, not asked of the daemon: the watcher that + // carries the accent does not race the handshake, and this screen draws + // before `main_loop`, the only thing draining the connection. `[theme]` + // names what a session with no stored colour starts in. let session_accent = crate::session::prefs::PrefsStore::load_seeded(cfg.theme.preset_index()) .get() .accent; - // The splash is not the only screen that draws before the daemon's first - // set arrives. Without this the first frames of the main view would come up - // in the default rather than the session's colour. + // The splash and the first frames both draw before the daemon's first set + // arrives; without this they would come up in the default colour. ws.set_accent_index(session_accent); if matches!( @@ -75,9 +73,9 @@ pub(crate) fn run_attach() -> Result<()> { tracing::info!("nightcrow detached during splash"); return Ok(()); } - // The view state is written whichever way the loop ends. Losing which file - // was selected because the daemon stopped would be a second insult, and this - // half of the session file is the client's own. + // The view state is written whichever way the loop ends: losing which file + // was selected because the daemon stopped would be a second insult, and + // this half of the session file is the client's own. let ended = main_loop( &mut terminal, &mut ws, @@ -96,16 +94,14 @@ pub(crate) fn run_attach() -> Result<()> { /// Write this client's view state back, leaving the tab list alone. /// /// The file has two halves and two owners: the daemon writes which -/// repositories are open and which is active, and a client writes what it had -/// selected and where it had scrolled. Read-modify-write rather than a whole -/// rewrite, so detaching cannot roll the session's tab list back to whatever -/// this client happened to be showing. +/// repositories are open and which is active; a client writes what it had +/// selected and where it had scrolled. Read-modify-write, so detaching cannot +/// roll the session's tab list back to whatever this client was showing. /// -/// The two can still race — a client detaching in the same instant a -/// repository is opened elsewhere can lose that open until the next change -/// rewrites it. That is the same self-correcting transient the viewer's -/// preference writes already accept, and closing it would mean putting a lock -/// around a file two processes touch seconds apart. +/// The two can still race — a client detaching as a repository is opened +/// elsewhere can lose that open until the next change rewrites it. The same +/// transient the viewer's preference writes accept; closing it would mean +/// locking a file two processes touch seconds apart. fn persist_view_state(ws: &Workspace) { let mut stored = crate::workspace::persistence::load_workspace().unwrap_or_default(); stored.sessions = ws.view_state(); diff --git a/src/application/bootstrap.rs b/src/application/bootstrap.rs index b0b73bcd..459777aa 100644 --- a/src/application/bootstrap.rs +++ b/src/application/bootstrap.rs @@ -9,20 +9,21 @@ pub(crate) fn init_app( backend: Box, ) -> App { let mut app = App::new(repo_path.to_string(), cfg.log.prompt_log, leader, backend); - app.cfg_agent_indicator = cfg.agent_indicator.clone(); - app.cfg_tree = cfg.tree.clone(); + app.configure_repository_views(cfg.agent_indicator.clone(), cfg.tree.clone()); app.interaction.mouse_enabled = cfg.mouse.enabled; if cfg.tree.live_watch { - app.tree_watch = crate::runtime::tree_watch::TreeWatcher::new(); + app.enable_tree_watcher(); } - app.pagination.page_size = cfg.log.commit_log_page_size; - app.pagination.prefetch_threshold = cfg.log.commit_log_prefetch_threshold; + app.configure_commit_log( + cfg.log.commit_log_page_size, + cfg.log.commit_log_prefetch_threshold, + ); if let Some(state) = saved_session { // Applied up front rather than on the first snapshot: only the Status // selection needs the changed-file list, and it waits in // `pending_selection` (see `App::restore_session`). The terminal half - // waits too, for the panes to arrive from the session, which replaces - // the fresh-launch default rather than fighting it. + // waits for the panes to arrive from the session, which replaces the + // fresh-launch default rather than fighting it. app.restore_session(&state); } app diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index 9fb291f7..a9172fc9 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -2,12 +2,13 @@ pub(crate) use crate::application::input::dispatch::ProjectContext; use crate::application::input::dispatch::{KeyOutcome, dispatch_key}; use crate::application::input::mouse::dispatch_mouse; use crate::application::input::paste::dispatch_paste; +use crate::application::redraw::{RedrawCause, RedrawState}; use crate::application::session_link::SessionLink; use crate::application::terminal_guard::TuiTerminal; use crate::workspace::Workspace; use crossterm::event::{self, Event}; use ratatui::layout::Rect; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; @@ -21,10 +22,13 @@ pub(crate) fn main_loop( mut link: SessionLink, ) -> anyhow::Result<()> { let blink_started = std::time::Instant::now(); + let mut redraw = RedrawState::new(); loop { // Whoever owns the tab list gets the first word each tick: attached, // the set may have changed under this client since the last frame. - link.sync(ws, ctx); + if link.sync(ws, ctx) { + redraw.request(RedrawCause::Session); + } if !link.is_connected() { tracing::info!("daemon connection lost"); // Reported rather than returned quietly. Leaving on a lost @@ -38,33 +42,42 @@ pub(crate) fn main_loop( ); } // Every project drains its queues, not just the visible one: the - // snapshot worker and PTY reader produce into unbounded channels - // regardless of which tab is on screen. - // - // Only the active project *applies* its snapshot, though. A background - // snapshot waits in `pending_snapshot` until its tab is shown. + // snapshot worker and PTY reader keep producing regardless of which + // tab is on screen. Only the active project + // *applies* its snapshot, though — a background one waits in + // `pending_snapshot` until its tab is shown. let active = ws.active_index(); for (i, project) in ws.projects_mut().iter_mut().enumerate() { + if project.poll_git_loads() { + redraw.request(RedrawCause::Git); + } if i == active { - project.poll_snapshot(); - // Applying a commit-log page can trigger a further prefetch and - // load a commit diff synchronously, so it stays with the - // snapshot as active-only work. - project.poll_commit_log_page_fetch(); + if project.poll_snapshot() { + redraw.request(RedrawCause::Snapshot); + } + // Stays with the snapshot as active-only work: applying a + // commit-log page can trigger a further prefetch and load a + // commit diff on the git-load worker. + if project.poll_commit_log_page_fetch() { + redraw.request(RedrawCause::Log); + } } else { project.drain_snapshot(); } - // Both are cheap drains that must run everywhere: the tree watcher - // to keep OS filesystem events from piling up, the terminal to - // consume PTY output before the pipe fills and blocks the child. - // Acting on a watcher event is active-only; a hidden project - // records the event and refreshes when its tab comes forward. + // Cheap drains that must run everywhere: the tree watcher so OS + // filesystem events do not pile up, the terminal so PTY output is + // consumed before the pipe fills and blocks the child. Acting on a + // watcher event is active-only; a hidden project records the event. if i == active { - project.poll_tree_watcher(); + if project.poll_tree_watcher() { + redraw.request(RedrawCause::Tree); + } } else { project.drain_tree_watcher(); } - project.poll_terminal(); + if project.poll_terminal() { + redraw.request(RedrawCause::Terminal); + } } // Project-tab attention is client-local and means "not seen on this // screen". The project in front has just consumed its terminal events, @@ -73,6 +86,7 @@ pub(crate) fn main_loop( let size = terminal.size()?; let screen = Rect::new(0, 0, size.width, size.height); + redraw.observe_screen(size.width, size.height); if let Some(app) = ws.active() { let layouts: Vec<(crate::backend::PaneId, u16, u16)> = crate::ui::terminal_content_areas(app, screen, &cfg.layout) @@ -86,14 +100,29 @@ pub(crate) fn main_loop( // Collected before the mutable borrow of the active project, since the // tab row names every project while the body renders only one. Bounded - // by `MAX_PROJECTS`, so the per-frame clone is a handful of short - // strings. - let tab_paths: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); + // by `MAX_PROJECTS`, so the per-frame clone is a handful of strings. + let tab_paths: Vec = ws + .projects() + .iter() + .map(|p| p.repository_path().to_string()) + .collect(); let tab_attention: Vec = ws .projects() .iter() .map(|project| project.terminal.has_unread_attention()) .collect(); + let has_attention = tab_attention.iter().any(|attention| *attention); + let attention_bright = crate::ui::project_tab::blink_is_bright(blink_started.elapsed()); + redraw.observe_attention(has_attention, attention_bright); + let now = SystemTime::now(); + let hot_deadline = ws + .active() + .and_then(|app| crate::ui::next_hot_deadline_for_app(app, now)); + redraw.observe_hot_deadline(hot_deadline, now); + let caret_active = ws + .active() + .is_some_and(crate::app::App::search_overlay_active); + redraw.observe_caret(caret_active, crate::ui::current_caret_lit()); let active_tab = ws.active_index(); let empty_notice = ws.empty_notice().cloned(); let prefix_armed = ws.prefix_armed(); @@ -102,45 +131,47 @@ pub(crate) fn main_loop( // the borrow the projects need. let accent = ws.current_accent(); - let (app_opt, repo_input) = ws.render_parts(); - let tabs = crate::ui::Chrome { - repo_paths: &tab_paths, - attention: &tab_attention, - attention_bright: crate::ui::project_tab::blink_is_bright(blink_started.elapsed()), - active: active_tab, - repo_input, - }; - terminal.draw(|frame| match app_opt { - Some(app) => { - crate::ui::draw(frame, app, tabs, ss, ts, &cfg.layout, accent); - } - None => crate::ui::draw_empty( - frame, - tabs, - empty_notice.as_ref(), - ctx.leader, - prefix_armed, - cfg.mouse.enabled, - accent, - ), - })?; + if redraw.take() { + let (app_opt, repo_input) = ws.render_parts(); + let tabs = crate::ui::Chrome { + repo_paths: &tab_paths, + attention: &tab_attention, + attention_bright, + active: active_tab, + repo_input, + }; + terminal.draw(|frame| match app_opt { + Some(app) => { + crate::ui::draw(frame, app, tabs, ss, ts, &cfg.layout, accent); + } + None => crate::ui::draw_empty( + frame, + tabs, + empty_notice.as_ref(), + ctx.leader, + prefix_armed, + cfg.mouse.enabled, + accent, + ), + })?; + } // `tabs` above borrows the workspace for the draw; input needs it // mutably, so rebuild the same view over a snapshot of the dialog. - // Only the buffer is copied, and only on frames that see an event. + // Only the buffer is copied here; the frame itself may be skipped when + // no state or visual clock phase changed. let repo_input = ws.repo_input.clone(); let tabs = crate::ui::Chrome { repo_paths: &tab_paths, attention: &tab_attention, - attention_bright: crate::ui::project_tab::blink_is_bright(blink_started.elapsed()), + attention_bright, active: active_tab, repo_input: &repo_input, }; - // 16 ms ≈ 60 fps. The previous 50 ms tick noticeably lagged PTY echo - // on every keystroke (typing felt sticky). `event::poll` performs an - // OS-level wait when nothing is happening, so the higher cap doesn't - // burn CPU at idle. + // 16 ms ≈ 60 fps is only the polling latency cap. Unlike the old frame + // clock, an idle tick does not draw; the wait lets asynchronous PTY and + // watcher results be noticed without keeping a terminal frame alive. if event::poll(Duration::from_millis(16))? { let first = event::read()?; // Unix gets a real `Event::Paste` from crossterm; Windows never @@ -156,23 +187,39 @@ pub(crate) fn main_loop( // Ratatui's next draw will pick up the new size from // `Frame::area()`. An explicit clear() here only adds a // visible flash on resize without improving correctness. - Event::Resize(_, _) => {} + Event::Resize(_, _) => redraw.request(RedrawCause::Resize), Event::Key(key) => { + let pressed = key.kind == crossterm::event::KeyEventKind::Press; + if pressed { + redraw.request(RedrawCause::Input); + } let outcome = dispatch_key(ws, key); + let force_redraw = matches!(outcome, KeyOutcome::Redraw); if apply_outcome(terminal, ws, &mut link, outcome)? { return Ok(()); } + if force_redraw { + redraw.request(RedrawCause::Redraw); + } + } + Event::Paste(text) => { + redraw.request(RedrawCause::Input); + dispatch_paste(ws, &text); } - Event::Paste(text) => dispatch_paste(ws, &text), Event::Mouse(mouse) => { + redraw.request(RedrawCause::Input); let screen = Rect::new(0, 0, size.width, size.height); let outcome = dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); + let force_redraw = matches!(outcome, KeyOutcome::Redraw); if apply_outcome(terminal, ws, &mut link, outcome)? { return Ok(()); } + if force_redraw { + redraw.request(RedrawCause::Redraw); + } } - _ => {} + _ => redraw.request(RedrawCause::Input), } } } diff --git a/src/application/input/burst.rs b/src/application/input/burst.rs index b13ec968..b9dd3aff 100644 --- a/src/application/input/burst.rs +++ b/src/application/input/burst.rs @@ -48,9 +48,8 @@ pub(crate) fn classify(events: Vec) -> Vec { /// The payload this burst would paste, or `None` if it reads as typing. /// /// Narrow on purpose: a false positive submits typed keys as a block. Enter -/// hands the line off, so nothing typed can follow it within one burst — but -/// the Enter that *ends* a typed line has nothing after it, and that burst is -/// typing. What marks a paste is content the Enter did not submit. +/// hands the line off, so nothing typed can follow it within one burst — what +/// marks a paste is content the Enter did not submit. fn paste_text(events: &[Event]) -> Option { let mut text = String::new(); let mut enters = 0usize; diff --git a/src/application/input/dispatch.rs b/src/application/input/dispatch.rs index 0305fe8c..8ecd4383 100644 --- a/src/application/input/dispatch.rs +++ b/src/application/input/dispatch.rs @@ -44,20 +44,18 @@ pub(crate) struct ProjectContext<'a> { pub(crate) fn handle_key(app: &mut App, key: KeyEvent) -> KeyOutcome { // Crossterm emits Press/Repeat/Release for every keystroke on Windows - // and on terminals that negotiate the kitty keyboard protocol. - // Without this guard every keypress would be processed twice or more - // — visible as doubled search chars, the leader firing repeatedly, and - // Backspace popping past the buffer. + // and on kitty-protocol terminals; without this guard every keypress + // is processed two or more times — doubled search chars, the leader + // firing repeatedly, Backspace popping past the buffer. if key.kind != KeyEventKind::Press { return KeyOutcome::Continue; } // A key nightcrow acts on itself means the user has moved on, so the // notice row goes back to showing repo identity. Keys forwarded verbatim - // to a PTY are excluded: in a terminal pane every keystroke is - // passthrough, and dismissing on those would blank a notice the moment - // the user resumed typing. Runs before dispatch so an action that raises - // a *new* notice still leaves it standing. + // to a PTY are excluded — there, every keystroke is passthrough, and + // dismissing on those would blank a notice the moment typing resumed. + // Runs before dispatch so a new notice survives the same tick. if app.search_overlay_active() || app.interaction.prefix_armed || app.interaction.awaiting_swap_target @@ -68,30 +66,27 @@ pub(crate) fn handle_key(app: &mut App, key: KeyEvent) -> KeyOutcome { } // Modal overlays (repo-input dialog, both search bars) own every - // keystroke until dismissed. They are checked before any leader handling - // so a leader keypress while a search/repo dialog is open is typed/edited - // by the overlay rather than arming the prefix. + // keystroke until dismissed, and are checked before any leader handling + // so a leader press while one is open edits within the overlay rather + // than arming the prefix. if app.search_overlay_active() { // A prefix (or swap-target) could only be armed if an overlay opened - // out from under it; disarm both so neither indicator lingers behind a - // modal. + // out from under it; disarm both so neither indicator lingers. app.interaction.prefix_armed = false; app.interaction.awaiting_swap_target = false; - // Search overlays are handled inside the focus-local upper handler. handle_upper_key(app, key, Action::None); return KeyOutcome::Continue; } - // Swap-target mode is armed (` s`): this key is the digit naming - // the pane to swap the active pane with. Checked before the prefix so its - // dedicated follow-up handler owns the key. + // Swap-target mode is armed (` s`): this key names the pane to + // swap with. Checked before the prefix so its dedicated handler owns it. if app.interaction.awaiting_swap_target { return handle_swap_target_followup(app, key); } - // Prefix is armed: this key is the single follow-up. Resolve it three - // ways — Esc/Ctrl+C cancels, the leader again sends a literal leader to - // the PTY, a mapped key runs its action; any other key is consumed. + // Prefix is armed: this key is the single follow-up — Esc/Ctrl+C cancels, + // the leader again sends a literal leader to the PTY, a mapped key runs + // its action; anything else is consumed. if app.interaction.prefix_armed { return handle_prefix_followup(app, key); } @@ -133,8 +128,7 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option { // Scoped by `can_close_pane` (terminal focus — the close target - // is invisible without it). The key is still consumed so it - // can't leak elsewhere. + // is invisible without it); the key is consumed either way. if app.can_close_pane() { app.close_active_pane(); } @@ -162,14 +156,11 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option Some(KeyOutcome::Project(ProjectRequest::CycleAccent)), - // The config belongs to the session, so this asks too. What comes back is - // a notice rather than anything on screen: a reload replaces plugin - // children and the list future projects open with, neither of which this - // client is looking at. + // The config belongs to the session, so this asks too; what comes back + // is a notice rather than anything this client is looking at. Action::ReloadConfig => Some(KeyOutcome::Project(ProjectRequest::ReloadConfig)), Action::Redraw => Some(KeyOutcome::Redraw), Action::SwitchPane(n) => { @@ -177,8 +168,8 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option { - // Scoped by `can_swap_panes` (terminal focus plus a second pane). - // The key is still consumed either way. + // Scoped by `can_swap_panes` (terminal focus plus a second pane); + // the key is consumed either way. if app.can_swap_panes() { app.interaction.begin_swap_target(); } diff --git a/src/application/input/handlers.rs b/src/application/input/handlers.rs index 906a743d..b2bc20d5 100644 --- a/src/application/input/handlers.rs +++ b/src/application/input/handlers.rs @@ -13,11 +13,9 @@ use crossterm::event::{KeyCode, KeyEvent}; pub(crate) fn handle_empty_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { if ws.prefix_armed() { ws.cancel_prefix(); - // ` ` sends a literal leader to the focused PTY on the project - // screen; here there is no pane to send it to, so it is consumed. - // Resolving it before the action table matters: with the default - // `ctrl+f` leader the follow-up would otherwise match `f` and toggle - // fullscreen. + // ` ` is consumed here: there is no pane to send a literal + // leader to, and with the default `ctrl+f` leader the follow-up would + // otherwise match `f` and toggle fullscreen. if ws.is_leader_key(key) { return KeyOutcome::Continue; } @@ -63,21 +61,21 @@ pub(crate) fn handle_terminal_key(app: &mut App, key: KeyEvent, action: Action) } pub(crate) fn handle_upper_key(app: &mut App, key: KeyEvent, action: Action) { - if app.focus == Focus::FileList && app.status_view.search_active { + if app.focus == Focus::FileList && app.status_view().search_active { handle_file_search_key(app, key); return; } - if app.focus == Focus::FileList && app.tree_view.search_active { + if app.focus == Focus::FileList && app.tree_view().search_active { handle_tree_search_key(app, key); return; } if app.focus == Focus::FileList - && (app.log_view.commit_search_active || app.log_view.file_search_active) + && (app.log_view().commit_search_active || app.log_view().file_search_active) { handle_log_search_key(app, key); return; } - if app.focus == Focus::DiffViewer && app.diff.search.active { + if app.focus == Focus::DiffViewer && app.diff_pane_mut().search.active { handle_diff_search_key(app, key); return; } @@ -107,7 +105,7 @@ fn handle_file_search_key(app: &mut App, key: KeyEvent) { KeyCode::Esc => app.cancel_search(), KeyCode::Enter => app.confirm_search(), KeyCode::Backspace => { - if app.status_view.search_query.is_empty() { + if app.status_view().search_query.is_empty() { app.cancel_search(); } else { app.search_pop(); @@ -131,7 +129,7 @@ fn handle_tree_search_key(app: &mut App, key: KeyEvent) { KeyCode::Esc => app.cancel_tree_search(), KeyCode::Enter => app.confirm_tree_search(), KeyCode::Backspace => { - if app.tree_view.search_query.is_empty() { + if app.tree_view().search_query.is_empty() { app.cancel_tree_search(); } else { app.tree_search_pop(); @@ -156,10 +154,10 @@ fn handle_log_search_key(app: &mut App, key: KeyEvent) { KeyCode::Backspace => { // Which query is active depends on whether the drill-down file // list is showing; mirror the dispatch used by `log_search_push`. - let query_empty = if app.log_view.drill_down { - app.log_view.file_search_query.is_empty() + let query_empty = if app.log_view().drill_down { + app.log_view().file_search_query.is_empty() } else { - app.log_view.commit_search_query.is_empty() + app.log_view().commit_search_query.is_empty() }; if query_empty { app.cancel_log_search(); @@ -177,18 +175,18 @@ fn handle_log_search_key(app: &mut App, key: KeyEvent) { fn handle_diff_search_key(app: &mut App, key: KeyEvent) { match key.code { - KeyCode::Esc => app.diff.cancel_search(), - KeyCode::Enter => app.diff.confirm_search(), + KeyCode::Esc => app.diff_pane_mut().cancel_search(), + KeyCode::Enter => app.diff_pane_mut().confirm_search(), KeyCode::Backspace => { - if app.diff.search.query.is_empty() { - app.diff.cancel_search(); + if app.diff_pane_mut().search.query.is_empty() { + app.diff_pane_mut().cancel_search(); } else { - app.diff.search_pop(); + app.diff_pane_mut().search_pop(); } } _ => { if let Some(c) = text_input_char(key) { - app.diff.search_push(c); + app.diff_pane_mut().search_push(c); } } } @@ -197,44 +195,44 @@ fn handle_diff_search_key(app: &mut App, key: KeyEvent) { fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { match app.focus { Focus::FileList => match key.code { - KeyCode::Enter if app.mode == ViewMode::Log && !app.log_view.drill_down => { + KeyCode::Enter if app.mode() == ViewMode::Log && !app.log_view().drill_down => { app.log_drill_in() } // 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(), + 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 // first Esc clears a confirmed filter before a second Esc exits // drill-down — mirrors the status-search Esc rule below. KeyCode::Esc - if app.mode == ViewMode::Log - && app.log_view.drill_down - && !app.log_view.file_search_query.is_empty() => + if app.mode() == ViewMode::Log + && app.log_view().drill_down + && !app.log_view().file_search_query.is_empty() => { app.cancel_log_search() } KeyCode::Esc - if app.mode == ViewMode::Log - && !app.log_view.drill_down - && !app.log_view.commit_search_query.is_empty() => + if app.mode() == ViewMode::Log + && !app.log_view().drill_down + && !app.log_view().commit_search_query.is_empty() => { app.cancel_log_search() } - KeyCode::Esc if app.log_view.drill_down => app.log_drill_out(), - _ if app.mode == ViewMode::Status && matches_text_command(key, '/') => { + KeyCode::Esc if app.log_view().drill_down => app.log_drill_out(), + _ if app.mode() == ViewMode::Status && matches_text_command(key, '/') => { app.start_search() } - _ if app.mode == ViewMode::Tree && matches_text_command(key, '/') => { + _ if app.mode() == ViewMode::Tree && matches_text_command(key, '/') => { app.start_tree_search() } - _ if app.mode == ViewMode::Log && matches_text_command(key, '/') => { + _ if app.mode() == ViewMode::Log && matches_text_command(key, '/') => { app.start_log_search() } - KeyCode::Esc if !app.status_view.search_query.is_empty() => app.cancel_search(), + KeyCode::Esc if !app.status_view().search_query.is_empty() => app.cancel_search(), KeyCode::Left => app.file_scroll_left(), KeyCode::Right => app.file_scroll_right(), _ => {} @@ -250,19 +248,21 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { KeyCode::Tab => app.cycle_diff_view(), _ if matches_text_command(key, '/') => { exit_split_for_search(app); - app.diff.start_search(); + app.diff_pane_mut().start_search(); } - _ if matches_text_command(key, 'n') && app.diff.search.has_query() => { + _ if matches_text_command(key, 'n') && app.diff_pane_mut().search.has_query() => { exit_split_for_search(app); - app.diff.next_match(); + app.diff_pane_mut().next_match(); } - _ if matches_text_command(key, 'N') && app.diff.search.has_query() => { + _ if matches_text_command(key, 'N') && app.diff_pane_mut().search.has_query() => { exit_split_for_search(app); - app.diff.prev_match(); + app.diff_pane_mut().prev_match(); } - KeyCode::Esc if !app.diff.search.query.is_empty() => app.diff.cancel_search(), - KeyCode::Left => app.diff.scroll_left(), - KeyCode::Right => app.diff.scroll_right(), + KeyCode::Esc if !app.diff_pane_mut().search.query.is_empty() => { + app.diff_pane_mut().cancel_search() + } + KeyCode::Left => app.diff_pane_mut().scroll_left(), + KeyCode::Right => app.diff_pane_mut().scroll_right(), _ => {} }, Focus::Terminal => {} @@ -270,7 +270,7 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { } fn exit_split_for_search(app: &mut App) { - if app.diff.view == DiffPaneView::Split { - app.diff.view = DiffPaneView::Diff; + if app.diff_pane_mut().view == DiffPaneView::Split { + app.diff_pane_mut().view = DiffPaneView::Diff; } } diff --git a/src/application/input/mouse.rs b/src/application/input/mouse.rs index 97158b32..2b4afb9e 100644 --- a/src/application/input/mouse.rs +++ b/src/application/input/mouse.rs @@ -61,8 +61,8 @@ pub(crate) fn dispatch_mouse( /// Route a captured mouse event to the pane under the pointer. Releases pair /// with the press's pane (not the pointer pane); wheel scrolls the pane under /// the pointer; a left press outside pane content can focus an upper panel, -/// jump via a tab/`+N` marker, or run a hint-bar shortcut. In swap mode a -/// left click names the swap target. Drag/motion reports are not forwarded. +/// jump via a tab/`+N` marker, or run a hint-bar shortcut. In swap mode a left +/// click names the swap target. Drag/motion reports are not forwarded. pub(crate) fn handle_mouse( app: &mut App, tabs: crate::ui::Chrome<'_>, @@ -185,9 +185,9 @@ fn dispatch_hint_click(app: &mut App, click: crate::ui::HintClick) -> KeyOutcome /// Deliver a button release to the pane that received the matching press. /// The release carries the *stored* press button, not crossterm's: legacy /// encodings don't identify the button on release (some report every `Up` as -/// `Left`), so trusting that would strand a right/middle press without its -/// release. The release cell is clamped into the pressed pane's current rect; -/// if that pane was closed or hidden since, the release is dropped. +/// `Left`), so trusting that would strand a right/middle press. The release +/// cell is clamped into the pressed pane's current rect; if that pane was +/// closed or hidden since, the release is dropped. fn release_pending_press( app: &mut App, screen: Rect, diff --git a/src/application/input/paste.rs b/src/application/input/paste.rs index 212ecbbb..0667b593 100644 --- a/src/application/input/paste.rs +++ b/src/application/input/paste.rs @@ -21,50 +21,49 @@ pub(crate) fn dispatch_paste(ws: &mut Workspace, text: &str) { /// Route a bracketed-paste payload within one project. /// -/// Its search overlays accept the text after stripping control characters — -/// the same rule the typed-key handlers enforce. The terminal pane receives -/// the paste re-wrapped in `ESC [200~ ... ESC [201~` so the inner shell can +/// Search overlays accept the text after stripping control characters, the +/// same rule the typed-key handlers enforce. The terminal pane receives the +/// paste re-wrapped in `ESC [200~ ... ESC [201~` so the inner shell can /// distinguish multi-line paste from interactive input. `text` never carries /// the outer markers: crossterm strips them on Unix, and on Windows there are /// none — `input::burst` synthesises the event from keys. pub(crate) fn handle_paste(app: &mut App, text: &str) { - // A paste arriving while the prefix is armed would otherwise leave the - // PREFIX indicator stuck and make the next key resolve as a follow-up. - // Resolve the prefix first (tmux treats a non-command event as a cancel), - // then route the paste normally. + // A paste while the prefix is armed would leave the PREFIX indicator + // stuck and make the next key resolve as a follow-up; resolve the prefix + // first (tmux treats a non-command event as a cancel). app.interaction.prefix_armed = false; - if app.focus == Focus::FileList && app.status_view.search_active { + if app.focus == Focus::FileList && app.status_view().search_active { for ch in text.chars().filter(|c| !c.is_control()) { app.search_push(ch); } return; } - if app.focus == Focus::FileList && app.tree_view.search_active { + if app.focus == Focus::FileList && app.tree_view().search_active { for ch in text.chars().filter(|c| !c.is_control()) { app.tree_search_push(ch); } return; } if app.focus == Focus::FileList - && (app.log_view.commit_search_active || app.log_view.file_search_active) + && (app.log_view().commit_search_active || app.log_view().file_search_active) { for ch in text.chars().filter(|c| !c.is_control()) { app.log_search_push(ch); } return; } - if app.focus == Focus::DiffViewer && app.diff.search.active { + if app.focus == Focus::DiffViewer && app.diff_pane_mut().search.active { for ch in text.chars().filter(|c| !c.is_control()) { - app.diff.search_push(ch); + app.diff_pane_mut().search_push(ch); } return; } if app.focus == Focus::Terminal { - // Strip ESC (0x1b) and NUL (0x00) before forwarding: an embedded - // 0x1b can re-arm or cancel the bracketed-paste boundary the shell - // is parsing, and NUL is malformed for most line-buffered shells. - // Newlines, tabs, and other printable controls stay in — they are - // exactly what bracketed paste is meant to deliver atomically. + // Strip ESC and NUL before forwarding: an embedded 0x1b can re-arm or + // cancel the bracketed-paste boundary the shell is parsing, and NUL + // is malformed for most line-buffered shells. Newlines, tabs, and + // other printable controls stay — they are what bracketed paste + // delivers atomically. let sanitized: Vec = text .as_bytes() .iter() diff --git a/src/application/mod.rs b/src/application/mod.rs index 246d381d..5ba73616 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod attach; pub(crate) mod bootstrap; pub(crate) mod event_loop; pub(crate) mod input; +pub(crate) mod redraw; pub(crate) mod session_link; pub(crate) mod splash; pub(crate) mod terminal_guard; diff --git a/src/application/redraw.rs b/src/application/redraw.rs new file mode 100644 index 00000000..74bd3113 --- /dev/null +++ b/src/application/redraw.rs @@ -0,0 +1,229 @@ +//! Dirty-frame accounting for the attached TUI. +//! +//! Polling remains frequent so terminal output and watcher events are picked +//! up promptly, but an unchanged model does not need another frame. The +//! event loop records state-changing inputs and queue results here, while the +//! two clocks record visual changes that happen without an input event. + +use std::time::SystemTime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RedrawCause { + Initial, + Terminal, + Input, + Resize, + Snapshot, + Tree, + Git, + Log, + AttentionBlink, + CaretBlink, + HotFile, + Session, + Redraw, +} + +#[derive(Debug, Default)] +pub(crate) struct RedrawState { + dirty: bool, + screen: Option<(u16, u16)>, + attention_phase: Option, + caret_phase: Option, + hot_observed: bool, + hot_deadline: Option, +} + +impl RedrawState { + pub(crate) fn new() -> Self { + let mut state = Self::default(); + state.request(RedrawCause::Initial); + state + } + + pub(crate) fn request(&mut self, _cause: RedrawCause) { + self.dirty = true; + } + + pub(crate) fn observe_screen(&mut self, width: u16, height: u16) { + let screen = (width, height); + if self.screen != Some(screen) { + self.screen = Some(screen); + self.request(RedrawCause::Resize); + } + } + + pub(crate) fn observe_attention(&mut self, has_attention: bool, bright: bool) { + let phase = has_attention.then_some(bright); + if self.attention_phase != phase { + self.attention_phase = phase; + self.request(RedrawCause::AttentionBlink); + } + } + + pub(crate) fn observe_caret(&mut self, active: bool, lit: bool) { + let phase = active.then_some(lit); + if self.caret_phase != phase { + self.caret_phase = phase; + self.request(RedrawCause::CaretBlink); + } + } + + /// Schedule the next hot-file style transition without adding a periodic + /// frame clock. A crossed deadline dirties one frame; the caller then + /// supplies the following deadline calculated from the same clock. A + /// newly reachable or earlier deadline after the first observation means + /// the wall clock moved back across a rendered stage, so repaint once to + /// synchronize that stage too. + pub(crate) fn observe_hot_deadline(&mut self, next: Option, now: SystemTime) { + let crossed = self.hot_deadline.is_some_and(|deadline| now >= deadline); + let rolled_back = self.hot_observed + && match (self.hot_deadline, next) { + (None, Some(_)) => true, + (Some(previous), Some(next)) => next < previous, + _ => false, + }; + self.hot_observed = true; + self.hot_deadline = next; + if crossed || rolled_back { + self.request(RedrawCause::HotFile); + } + } + + pub(crate) fn take(&mut self) -> bool { + std::mem::take(&mut self.dirty) + } + + #[cfg(test)] + pub(crate) fn is_dirty(&self) -> bool { + self.dirty + } +} + +#[cfg(test)] +mod tests { + use super::{RedrawCause, RedrawState}; + use std::time::SystemTime; + + #[test] + fn initial_state_draws_once_then_stays_clean() { + let mut state = RedrawState::new(); + + assert!(state.take()); + assert!(!state.take()); + } + + #[test] + fn every_external_cause_marks_the_next_frame_dirty() { + let causes = [ + RedrawCause::Terminal, + RedrawCause::Input, + RedrawCause::Resize, + RedrawCause::Snapshot, + RedrawCause::Tree, + RedrawCause::Git, + RedrawCause::Log, + RedrawCause::HotFile, + RedrawCause::Session, + RedrawCause::Redraw, + ]; + + let mut state = RedrawState::new(); + state.take(); + for cause in causes { + state.request(cause); + assert!(state.take(), "{cause:?} must repaint"); + assert!(!state.is_dirty()); + } + } + + #[test] + fn screen_change_is_dirty_but_same_size_is_idle() { + let mut state = RedrawState::new(); + state.take(); + + state.observe_screen(100, 40); + assert!(state.take()); + state.observe_screen(100, 40); + assert!(!state.take()); + state.observe_screen(101, 40); + assert!(state.take()); + } + + #[test] + fn attention_and_caret_only_repaint_when_their_visible_phase_changes() { + let mut state = RedrawState::new(); + state.take(); + + state.observe_attention(true, true); + assert!(state.take()); + state.observe_attention(true, true); + assert!(!state.take()); + state.observe_attention(true, false); + assert!(state.take()); + state.observe_attention(false, true); + assert!(state.take()); + + state.observe_caret(true, true); + assert!(state.take()); + state.observe_caret(true, true); + assert!(!state.take()); + state.observe_caret(true, false); + assert!(state.take()); + state.observe_caret(false, true); + assert!(state.take()); + } + + #[test] + fn hot_deadline_dirties_exactly_once_at_each_stage_boundary() { + let mut state = RedrawState::new(); + state.take(); + let start = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(100); + let fresh_to_warm = start + std::time::Duration::from_secs(5); + let warm_to_cool = start + std::time::Duration::from_secs(15); + + state.observe_hot_deadline(Some(fresh_to_warm), start); + assert!(!state.take()); + state.observe_hot_deadline(Some(warm_to_cool), fresh_to_warm); + assert!(state.take()); + state.observe_hot_deadline(Some(warm_to_cool), fresh_to_warm); + assert!(!state.take()); + state.observe_hot_deadline(None, warm_to_cool); + assert!(state.take()); + state.observe_hot_deadline(None, warm_to_cool); + assert!(!state.take()); + } + + #[test] + fn hot_deadline_rollback_from_cool_to_fresh_dirties_exactly_once() { + let mut state = RedrawState::new(); + state.take(); + let cool_now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(120); + let rolled_back_now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(100); + let fresh_to_warm = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(105); + + state.observe_hot_deadline(None, cool_now); + assert!(!state.take()); + state.observe_hot_deadline(Some(fresh_to_warm), rolled_back_now); + assert!(state.take(), "cool-to-fresh rollback must repaint"); + state.observe_hot_deadline(Some(fresh_to_warm), rolled_back_now); + assert!(!state.take(), "the synchronized phase must stay idle"); + } + + #[test] + fn hot_deadline_rollback_from_warm_to_fresh_dirties_exactly_once() { + let mut state = RedrawState::new(); + state.take(); + let warm_now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(110); + let warm_to_cool = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(115); + let rolled_back_now = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(102); + let fresh_to_warm = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(105); + + state.observe_hot_deadline(Some(warm_to_cool), warm_now); + assert!(!state.take()); + state.observe_hot_deadline(Some(fresh_to_warm), rolled_back_now); + assert!(state.take(), "warm-to-fresh rollback must repaint"); + state.observe_hot_deadline(Some(fresh_to_warm), rolled_back_now); + assert!(!state.take(), "the synchronized phase must stay idle"); + } +} diff --git a/src/application/session_link.rs b/src/application/session_link.rs index 224adc13..f94499ca 100644 --- a/src/application/session_link.rs +++ b/src/application/session_link.rs @@ -1,10 +1,9 @@ //! The client's half of the shared tab list. //! -//! The daemon owns which repositories are open and in what order. This client -//! asks for a change and adopts whatever comes back — including changes another -//! client made. Which tab is in front is the daemon's too, so switching is a -//! request and every client follows the answer. What stays local is everything -//! *inside* a project — the view mode, the cursor, the scroll. +//! The daemon owns the tab list — which repositories are open, their order, +//! which is in front. This client asks and adopts whatever comes back, +//! including changes another client made; what stays local is everything +//! *inside* a project (view mode, cursor, scroll). use crate::application::bootstrap::init_app; use crate::application::input::dispatch::{ProjectContext, ProjectRequest}; @@ -22,8 +21,8 @@ impl SessionLink { Self { client } } - /// Take in everything the daemon has said since the last tick. - pub(crate) fn sync(&mut self, ws: &mut Workspace, ctx: &ProjectContext) { + pub(crate) fn sync(&mut self, ws: &mut Workspace, ctx: &ProjectContext) -> bool { + let mut changed = false; for message in self.client.drain() { match message { ServerMessage::Repos { @@ -41,16 +40,19 @@ impl SessionLink { // Adopted whether or not this client asked: the colour may // have been picked in a browser, or in another terminal. ws.set_accent_index(accent); + changed = true; } // A refusal this client asked for — a path that is not a // directory, or one repository too many. ServerMessage::Error { message } => { ws.raise_notice(crate::app::NoticeKind::Project, message); + changed = true; } // Shown where the refusal above is shown, because the two are the // same answer to the same request. ServerMessage::Reloaded { summary } => { ws.raise_notice(crate::app::NoticeKind::Session, summary); + changed = true; } // Answered during the handshake; a later one would mean the // daemon restarted under this client. @@ -60,20 +62,25 @@ impl SessionLink { ServerMessage::Terminal { repo, event } => { if let HubServerMessage::Error { message } = event { notify_repo(ws, &repo, message); + changed = true; } } } } + changed } - /// Carry out a tab request locally, or send it to the daemon. pub(crate) fn request(&mut self, ws: &mut Workspace, request: ProjectRequest) { let sent = match request { // Which project is in front is the session's, so this asks. Nothing // moves locally in the meantime: switching optimistically and then // being corrected would show a tab flicking past on every switch. ProjectRequest::Switch(index) => { - match ws.projects().get(index).and_then(|app| app.repo_id.clone()) { + match ws + .projects() + .get(index) + .and_then(|app| app.repository_id().map(str::to_string)) + { Some(id) => self.client.focus_repo(&id), // A tab the daemon has not named yet — it is a beat from // arriving, and there is nothing to ask about. @@ -92,7 +99,10 @@ impl SessionLink { // Closing is by id, so a tab with no id is not one the daemon knows // about and closing it locally would only hide it until the next // broadcast put it back. - ProjectRequest::Close => match ws.active().and_then(|app| app.repo_id.clone()) { + ProjectRequest::Close => match ws + .active() + .and_then(|app| app.repository_id().map(str::to_string)) + { Some(id) => self.client.close_repo(&id), None => return, }, @@ -114,7 +124,6 @@ impl SessionLink { } } - /// Whether the daemon is still there. pub(crate) fn is_connected(&self) -> bool { self.client.is_connected() } @@ -128,7 +137,7 @@ fn focus_repo(ws: &mut Workspace, repo: &str) -> bool { match ws .projects() .iter() - .position(|app| app.repo_id.as_deref() == Some(repo)) + .position(|app| app.repository_id() == Some(repo)) { Some(index) => { ws.switch(index); @@ -138,18 +147,15 @@ fn focus_repo(ws: &mut Workspace, repo: &str) -> bool { } } -/// Raise a terminal refusal on the tab it came from. -/// -/// By repository, not on the active tab: the client subscribes to every open -/// repository's terminals, so a refusal can be about one the user is not looking -/// at, and putting it on whatever tab is in front would name the wrong project. -/// A repository with no tab yet falls back to the active one rather than losing -/// the message. +/// Raise a terminal refusal on the tab it came from, not the active one: the +/// client subscribes to every open repository, so the refusal may be about a +/// tab the user is not looking at. A repository with no tab yet falls back to +/// the active one rather than losing the message. fn notify_repo(ws: &mut Workspace, repo: &str, message: String) { match ws .projects_mut() .iter_mut() - .find(|project| project.repo_id.as_deref() == Some(repo)) + .find(|project| project.repository_id() == Some(repo)) { Some(project) => project.raise_notice(crate::app::NoticeKind::Terminal, message), None => ws.raise_notice(crate::app::NoticeKind::Terminal, message), @@ -160,15 +166,14 @@ fn notify_repo(ws: &mut Workspace, repo: &str, message: String) { /// /// Membership first, then order, then the ids — a tab that stays open keeps its /// terminals, scroll, and selection, so reconciling in place matters more than -/// it would if this rebuilt from scratch. +/// rebuilding from scratch would. fn adopt(ws: &mut Workspace, ctx: &ProjectContext, repos: &[RepoSummary], client: &DaemonClient) { - // Closing first frees room under `MAX_PROJECTS` for what is being opened, - // so a set that swaps one repository for another fits in a single pass. + // Closing first frees room under `MAX_PROJECTS` for what is being opened. let wanted: Vec<&str> = repos.iter().map(|repo| repo.path.as_str()).collect(); let doomed: Vec = ws .projects() .iter() - .map(|project| project.repo_path.clone()) + .map(|project| project.repository_path().to_string()) .filter(|path| !wanted.contains(&path.as_str())) .collect(); for path in doomed { diff --git a/src/application/session_link_tests.rs b/src/application/session_link_tests.rs index 567442c6..4c5e5eb4 100644 --- a/src/application/session_link_tests.rs +++ b/src/application/session_link_tests.rs @@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; fn project_at(path: &str) -> App { let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = path.to_string(); + app.git.repo_path = path.to_string(); app } diff --git a/src/application/splash.rs b/src/application/splash.rs index 7cdcfbed..425da1bd 100644 --- a/src/application/splash.rs +++ b/src/application/splash.rs @@ -8,10 +8,9 @@ pub(crate) enum SplashOutcome { /// Run the splash until it times out or a key dismisses it. /// -/// `accent_idx` is the session's, read from its file rather than taken from the -/// daemon: the splash draws before this client has attached, so the broadcast -/// that carries the colour has not arrived yet. Reading it here is what keeps -/// the splash and the view a moment later from being two different colours. +/// `accent_idx` is the session's, read from its file rather than taken from +/// the daemon: the splash draws before this client has attached, so the +/// broadcast that carries the colour has not arrived yet. pub(crate) fn splash_loop( terminal: &mut TuiTerminal, accent_idx: usize, @@ -27,11 +26,8 @@ pub(crate) fn splash_loop( } if event::poll(std::time::Duration::from_millis(16))? { match event::read()? { - // Honour Esc so the user can abort during the splash instead - // of being forced to wait for it to clear and quit from the - // main view. (Leader-based quit needs a two-key sequence, so - // it isn't recognised on the one-shot splash screen.) Any - // other key dismisses the splash. + // Esc aborts during the splash (the leader needs two keys, so + // it is not recognised here); any other key dismisses it. Event::Key(k) if k.kind == KeyEventKind::Press => { if k.code == KeyCode::Esc { return Ok(SplashOutcome::Quit); diff --git a/src/application/terminal_guard.rs b/src/application/terminal_guard.rs index 32fa7af3..f850e6e8 100644 --- a/src/application/terminal_guard.rs +++ b/src/application/terminal_guard.rs @@ -44,16 +44,14 @@ pub(crate) struct TerminalGuard; impl TerminalGuard { pub(crate) fn enter(mouse: bool) -> Result { enable_raw_mode()?; - // EnableBracketedPaste makes crossterm surface paste as - // `Event::Paste(String)` instead of a flood of `Event::Key` chars — - // the latter would each be filtered as control chars by the search - // handler and silently drop newlines. Unix only — the Windows console - // has no paste record, so `input::burst` reassembles the flood. - // Ratatui positions every changed cell itself. Host-side autowrap is - // therefore both unnecessary and dangerous: writing the bottom-right - // cell can scroll the physical screen while Ratatui's back buffer still - // describes the pre-scroll frame, leaving duplicated rows and stale - // fragments on subsequent partial draws. + // DisableLineWrap: Ratatui positions every changed cell itself, so + // host-side autowrap is unnecessary and dangerous — writing the + // bottom-right cell can scroll the physical screen while Ratatui's + // back buffer still describes the pre-scroll frame, leaving duplicated + // rows on later partial draws. (EnableBracketedPaste, below, makes + // paste arrive as one `Event::Paste` instead of a key flood that + // search handlers would filter into silent data loss; Windows has no + // paste record, so `input::burst` reassembles it there.) if let Err(err) = execute!(io::stdout(), EnterAlternateScreen, DisableLineWrap) { restore_terminal(); return Err(err.into()); @@ -71,9 +69,8 @@ impl TerminalGuard { // prefer plain-drag selection can hand the mouse back entirely. if mouse && let Err(err) = execute!(io::stdout(), EnableMouseCapture) { // The enable may have partially reached the terminal even though - // the call errored (e.g. the write landed but a later flush - // failed), and no TerminalGuard exists yet to undo it on drop — - // send the disable explicitly; it is harmless when capture never + // the call errored, and no TerminalGuard exists yet to undo it on + // drop — send the disable explicitly; harmless if capture never // took effect. restore_terminal(); return Err(err.into()); diff --git a/src/application/tests/enter_fullscreen.rs b/src/application/tests/enter_fullscreen.rs index dd6603aa..7e909231 100644 --- a/src/application/tests/enter_fullscreen.rs +++ b/src/application/tests/enter_fullscreen.rs @@ -12,8 +12,14 @@ fn enter_in_diff_viewer_toggles_diff_fullscreen() { app.focus = Focus::DiffViewer; let _ = handle_key(&mut app, press(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.diff.fullscreen, "Enter must zoom the diff pane"); + assert!( + app.git.view.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"); + assert!( + !app.git.view.diff.fullscreen, + "a second Enter must exit the zoom" + ); } diff --git a/src/application/tests/helpers.rs b/src/application/tests/helpers.rs index b9e7baca..03c980b8 100644 --- a/src/application/tests/helpers.rs +++ b/src/application/tests/helpers.rs @@ -51,7 +51,7 @@ pub(super) fn app_with_terminal_pane() -> App { pub(super) fn workspace_on(paths: &[&str]) -> Workspace { let project = |p: &str| { let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = p.to_string(); + app.git.repo_path = p.to_string(); app }; let mut ws = Workspace::new(leader()); diff --git a/src/application/tests/mod.rs b/src/application/tests/mod.rs index ac1f4f14..9b0e13e8 100644 --- a/src/application/tests/mod.rs +++ b/src/application/tests/mod.rs @@ -9,6 +9,7 @@ mod paste; mod paste_burst; mod prefix; mod prefix_digits; +mod redraw_benchmark; mod reload; mod repo_dialog; mod search; diff --git a/src/application/tests/mouse.rs b/src/application/tests/mouse.rs index 202b4266..a4d3e042 100644 --- a/src/application/tests/mouse.rs +++ b/src/application/tests/mouse.rs @@ -106,7 +106,7 @@ fn handle_mouse_click_focuses_the_upper_panels() { fn handle_mouse_is_inert_while_a_search_overlay_is_open() { let (mut app, areas) = app_with_two_panes_and_areas(); app.focus = Focus::FileList; - app.status_view.search_active = true; + app.git.view.status.search_active = true; let (_, rect) = areas[0]; let active_before = app.terminal.active; diff --git a/src/application/tests/paste.rs b/src/application/tests/paste.rs index 168b9592..cadf38c7 100644 --- a/src/application/tests/paste.rs +++ b/src/application/tests/paste.rs @@ -59,18 +59,18 @@ fn handle_paste_into_file_search_strips_control_chars() { handle_paste(&mut app, "al\nph\ta\x07"); - assert_eq!(app.status_view.search_query.as_str(), "alpha"); + assert_eq!(app.git.view.status.search_query.as_str(), "alpha"); } #[test] fn handle_paste_into_diff_search_strips_control_chars() { let mut app = app_with_files(vec!["alpha.rs"]); app.focus = Focus::DiffViewer; - app.diff.start_search(); + app.git.view.diff.start_search(); handle_paste(&mut app, "fn\rname\x08"); - assert_eq!(app.diff.search.query.as_str(), "fnname"); + assert_eq!(app.git.view.diff.search.query.as_str(), "fnname"); } #[test] diff --git a/src/application/tests/prefix.rs b/src/application/tests/prefix.rs index 7b30edd9..04b389ee 100644 --- a/src/application/tests/prefix.rs +++ b/src/application/tests/prefix.rs @@ -264,11 +264,11 @@ fn handle_key_leader_l_toggles_log_view_from_upper_focus() { // terminal focus. let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::FileList; - let before = app.mode; + let before = app.git.view.mode; let _ = handle_key(&mut app, leader()); let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); assert_ne!( - app.mode, before, + app.git.view.mode, before, "leader+l must toggle the view in upper focus" ); } diff --git a/src/application/tests/prefix_digits.rs b/src/application/tests/prefix_digits.rs index e6740803..becb2679 100644 --- a/src/application/tests/prefix_digits.rs +++ b/src/application/tests/prefix_digits.rs @@ -60,15 +60,15 @@ fn handle_key_leader_b_toggles_tree_mode() { // Status. Uses the live cwd repo (the crate root) for the root read. let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::FileList; - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); let _ = handle_key(&mut app, leader()); let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.git.view.mode, ViewMode::Tree); let _ = handle_key(&mut app, leader()); let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.git.view.mode, ViewMode::Status); } #[test] @@ -79,21 +79,25 @@ fn handle_key_tree_right_left_expand_and_collapse() { std::fs::write(root.join("sub").join("f.txt"), "x").unwrap(); let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); + app.git.repo_path = path.clone(); app.focus = Focus::FileList; app.enter_tree_mode(); let idx = app - .tree_view + .git + .view + .tree .visible_rows() .iter() .position(|r| r.path == "sub") .unwrap(); - app.tree_view.selected = idx; + app.git.view.tree.selected = idx; // Right expands the directory. let _ = handle_key(&mut app, press(KeyCode::Right, KeyModifiers::NONE)); assert!( - app.tree_view + app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "sub/f.txt"), @@ -103,7 +107,9 @@ fn handle_key_tree_right_left_expand_and_collapse() { // Left collapses it again. let _ = handle_key(&mut app, press(KeyCode::Left, KeyModifiers::NONE)); assert!( - !app.tree_view + !app.git + .view + .tree .visible_rows() .iter() .any(|r| r.path == "sub/f.txt"), diff --git a/src/application/tests/redraw_benchmark.rs b/src/application/tests/redraw_benchmark.rs new file mode 100644 index 00000000..e1a97e39 --- /dev/null +++ b/src/application/tests/redraw_benchmark.rs @@ -0,0 +1,186 @@ +//! Release-only measurements for the dirty-frame event loop. +//! +//! Run explicitly because the PTY half starts a real shell: +//! +//! ```text +//! cargo test --release measure_dirty_redraw -- --ignored --nocapture +//! ``` + +use crate::application::redraw::{RedrawCause, RedrawState}; +#[cfg(unix)] +use crate::backend::{PtyBackend, TerminalBackend}; +#[cfg(unix)] +use crate::config::ShellConfig; +use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; +use std::time::{Duration, Instant}; + +const TICKS: usize = 600; +const TICK: Duration = Duration::from_millis(16); +#[cfg(unix)] +const PTY_SAMPLES: usize = 20; +#[cfg(unix)] +const PTY_TIMEOUT: Duration = Duration::from_secs(5); + +struct DrawRun { + draws: usize, + elapsed: Duration, +} + +fn draw_frame(terminal: &mut Terminal) { + terminal + .draw(|frame| frame.render_widget(Paragraph::new("nightcrow frame"), frame.area())) + .expect("test backend draw must succeed"); +} + +fn run_unconditional(ticks: usize) -> DrawRun { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test backend"); + let started = Instant::now(); + for _ in 0..ticks { + draw_frame(&mut terminal); + } + DrawRun { + draws: ticks, + elapsed: started.elapsed(), + } +} + +fn run_dirty(ticks: usize, event_every: Option) -> DrawRun { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test backend"); + let mut state = RedrawState::new(); + let started = Instant::now(); + let mut draws = 0; + for tick in 0..ticks { + if event_every.is_some_and(|period| tick % period == 0) { + state.request(RedrawCause::Terminal); + } + if state.take() { + draw_frame(&mut terminal); + draws += 1; + } + } + DrawRun { + draws, + elapsed: started.elapsed(), + } +} + +fn p95(mut samples: Vec) -> Duration { + samples.sort_unstable(); + let rank = (samples.len() * 95).div_ceil(100).saturating_sub(1); + samples[rank] +} + +/// Events arrive after the current frame's draw in `main_loop`, so the next +/// frame is bounded by one input-poll interval. Keep this virtual measurement +/// beside the real PTY sample to make the latency claim explicit and stable. +fn simulated_event_latency_p95(ticks: usize, event_every: usize) -> Duration { + let samples = (0..ticks).step_by(event_every).map(|_| TICK).collect(); + p95(samples) +} + +#[cfg(unix)] +fn pty_command(marker: &str) -> Vec { + format!("printf '{marker}\\n'\n").into_bytes() +} + +#[cfg(unix)] +fn measure_pty_echo() -> Vec { + let mut backend = PtyBackend::new(".", ShellConfig::default()); + let pane = backend + .open_pane(24, 80, None) + .expect("benchmark PTY must open"); + + // Let the shell's startup output settle before sampling command echoes. + let warmup_deadline = Instant::now() + Duration::from_millis(250); + while Instant::now() < warmup_deadline { + let _ = backend.drain_events(); + std::thread::sleep(TICK); + } + + let mut samples = Vec::with_capacity(PTY_SAMPLES); + for i in 0..PTY_SAMPLES { + let marker = format!("nightcrow-echo-{i}"); + backend + .send_input(pane, &pty_command(&marker)) + .expect("benchmark PTY input must succeed"); + let started = Instant::now(); + let deadline = started + PTY_TIMEOUT; + let mut output = Vec::new(); + let mut seen = false; + while Instant::now() < deadline { + for event in backend.drain_events() { + if let crate::backend::BackendEvent::Output { pane: id, data } = event + && id == pane + { + output.extend(data); + if output + .windows(marker.len()) + .any(|window| window == marker.as_bytes()) + { + seen = true; + break; + } + } + } + if seen { + break; + } + std::thread::sleep(TICK); + } + assert!( + seen, + "PTY marker {marker} did not arrive before {PTY_TIMEOUT:?}" + ); + samples.push(started.elapsed()); + } + backend.destroy_pane(pane); + samples +} + +#[test] +#[ignore = "release benchmark; starts a real shell and reports machine-specific timings"] +fn measure_dirty_redraw() { + let before = run_unconditional(TICKS); + let idle = run_dirty(TICKS, None); + let heartbeat = run_dirty(TICKS, Some(60)); + let active = run_dirty(TICKS, Some(1)); + let before_latency = simulated_event_latency_p95(TICKS, 1); + let after_latency = simulated_event_latency_p95(TICKS, 1); + + assert_eq!(idle.draws, 1, "idle should retain only the initial frame"); + assert_eq!(heartbeat.draws, TICKS / 60, "one heartbeat per second"); + assert_eq!( + active.draws, TICKS, + "an event every tick remains responsive" + ); + + println!( + "draws/10s@60fps: before={} idle={} heartbeat={} active={}", + before.draws, idle.draws, heartbeat.draws, active.draws + ); + println!( + "cpu-ms/10s-simulation: before={:.3} idle={:.3} heartbeat={:.3} active={:.3}", + before.elapsed.as_secs_f64() * 1000.0, + idle.elapsed.as_secs_f64() * 1000.0, + heartbeat.elapsed.as_secs_f64() * 1000.0, + active.elapsed.as_secs_f64() * 1000.0 + ); + println!( + "event-to-next-frame-p95-ms: before={:.3} after={:.3} (poll cap {:?})", + before_latency.as_secs_f64() * 1000.0, + after_latency.as_secs_f64() * 1000.0, + TICK + ); + #[cfg(unix)] + { + let echo = measure_pty_echo(); + println!( + "pty-echo-p95-ms: {:.3} ({} samples; poll cap {:?})", + p95(echo).as_secs_f64() * 1000.0, + PTY_SAMPLES, + TICK + ); + } + #[cfg(not(unix))] + println!("pty-echo-p95-ms: unavailable (headless Windows ConPTY)"); +} diff --git a/src/application/tests/search.rs b/src/application/tests/search.rs index 4cbdb7ab..c876cfe7 100644 --- a/src/application/tests/search.rs +++ b/src/application/tests/search.rs @@ -10,9 +10,9 @@ fn handle_key_overlay_blocks_leader_when_diff_search_active() { // overlay, never arming the prefix or firing an app command. let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.start_search(); - assert!(app.diff.search.active); - let before = app.mode; + app.git.view.diff.start_search(); + assert!(app.git.view.diff.search.active); + let before = app.git.view.mode; let _ = handle_key(&mut app, leader()); assert!( @@ -22,10 +22,13 @@ fn handle_key_overlay_blocks_leader_when_diff_search_active() { let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); assert_eq!( - app.mode, before, + app.git.view.mode, before, "no app command may fire behind an overlay" ); - assert!(app.diff.search.active, "diff search must remain open"); + assert!( + app.git.view.diff.search.active, + "diff search must remain open" + ); } #[test] @@ -37,19 +40,19 @@ fn handle_key_file_search_rejects_command_modifier_chars() { let ctrl_x = press(KeyCode::Char('x'), KeyModifiers::CONTROL); let _ = handle_key(&mut app, ctrl_x); - assert!(app.status_view.search_query.is_empty()); + assert!(app.git.view.status.search_query.is_empty()); } #[test] fn handle_key_diff_search_rejects_command_modifier_chars() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.start_search(); + app.git.view.diff.start_search(); let alt_x = press(KeyCode::Char('x'), KeyModifiers::ALT); let _ = handle_key(&mut app, alt_x); - assert!(app.diff.search.query.is_empty()); + assert!(app.git.view.diff.search.query.is_empty()); } #[test] @@ -60,7 +63,7 @@ fn handle_key_status_search_shortcut_requires_no_command_modifier() { let ctrl_slash = press(KeyCode::Char('/'), KeyModifiers::CONTROL); let _ = handle_key(&mut app, ctrl_slash); - assert!(!app.status_view.search_active); + assert!(!app.git.view.status.search_active); } #[test] @@ -71,31 +74,31 @@ fn handle_key_diff_file_toggle_requires_no_command_modifier() { let alt_v = press(KeyCode::Char('v'), KeyModifiers::ALT); let _ = handle_key(&mut app, alt_v); - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); } #[test] fn handle_key_diff_search_from_split_returns_to_unified_overlay() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::Split; + app.git.view.diff.view = DiffPaneView::Split; let _ = handle_key(&mut app, press(KeyCode::Char('/'), KeyModifiers::NONE)); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.search.active); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); + assert!(app.git.view.diff.search.active); } #[test] fn handle_key_diff_next_match_from_split_returns_to_unified_when_query_exists() { let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::Split; - app.diff.search.query.set("needle"); + app.git.view.diff.view = DiffPaneView::Split; + app.git.view.diff.search.query.set("needle"); let _ = handle_key(&mut app, press(KeyCode::Char('n'), KeyModifiers::NONE)); - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); } #[test] @@ -104,11 +107,11 @@ fn tab_in_the_diff_viewer_cycles_the_view() { // command, so it needs its own arm in the focus handler. let mut app = app_with_files(vec!["a.rs"]); app.focus = Focus::DiffViewer; - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); let _ = handle_key(&mut app, press(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.diff.view, DiffPaneView::Split); + assert_eq!(app.git.view.diff.view, DiffPaneView::Split); } #[test] @@ -120,5 +123,5 @@ fn tab_outside_the_diff_viewer_leaves_the_view_alone() { let _ = handle_key(&mut app, press(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.diff.view, DiffPaneView::Diff); + assert_eq!(app.git.view.diff.view, DiffPaneView::Diff); } diff --git a/src/application/tests/workspace.rs b/src/application/tests/workspace.rs index d1b785f9..0a2a29de 100644 --- a/src/application/tests/workspace.rs +++ b/src/application/tests/workspace.rs @@ -40,7 +40,7 @@ fn confirming_the_dialog_asks_the_workspace_to_open_that_path() { assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Open(expected))); // The current project still points at its original repo: confirming // opens a tab, it never repoints this one. - assert_eq!(ws.active().unwrap().repo_path, "/a"); + assert_eq!(ws.active().unwrap().git.repo_path, "/a"); assert!(!ws.repo_input.active, "dialog must close on success"); } diff --git a/src/backend/hub.rs b/src/backend/hub.rs index 797152e8..f1c4f278 100644 --- a/src/backend/hub.rs +++ b/src/backend/hub.rs @@ -9,7 +9,7 @@ //! VT emulation still happens here: the bytes are raw either way, so //! `PaneEmulator` reads them from a socket exactly as it read them from a PTY. -use super::{BackendEvent, PaneId, TerminalBackend}; +use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend}; use crate::daemon::terminal_link::{TerminalLink, TerminalMessage}; use crate::session::terminal::frame::{ ClientMessage as HubClientMessage, ServerMessage as HubServerMessage, @@ -26,12 +26,10 @@ impl HubBackend { Self { link } } - /// Take the daemon up on its offer to size the startup terminals. - /// - /// Answered with no sizes at all, because this client has measured nothing: - /// the offer arrives on attach, before the first frame has laid out a single - /// pane. The hub opens them at its own default and the first layout corrects - /// it. + /// Take the daemon up on its offer to size the startup terminals — with no + /// sizes at all, because the offer arrives on attach, before the first + /// frame has laid out a single pane. The hub opens them at its own default + /// and the first layout corrects it. fn size_startup_panes(&self) { if let Err(err) = self .link @@ -43,10 +41,8 @@ impl HubBackend { } impl TerminalBackend for HubBackend { - /// `command` is refused: a pane in a shared session is a bare shell. - /// - /// The session's configured commands are run once by the daemon, for every - /// client, so there is no request here that would carry one — and the hub + /// `command` is refused: a pane in a shared session is a bare shell — the + /// session's configured commands are run once by the daemon, and the hub /// deliberately gives a client no way to ask for a pane running arbitrary /// text. fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()> { @@ -64,28 +60,26 @@ impl TerminalBackend for HubBackend { /// Everything a client sends a pane is UTF-8 by construction — key /// encodings, pasted text, and the emulator's own replies to terminal - /// queries are all either ASCII control bytes or encoded characters — so the - /// text-shaped `input` message the browser already uses carries them - /// losslessly. Anything else is a bug on this side rather than something to - /// widen the wire format for, and is reported as one. + /// queries are all either ASCII control bytes or encoded characters — so + /// the text-shaped `input` message the browser already uses carries them + /// losslessly. Anything else is a bug on this side rather than something + /// to widen the wire format for. fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()> { let Ok(data) = String::from_utf8(data.to_vec()) else { - // The bytes themselves stay out of it: this is what the user typed, - // and the caller logs the error. The length is what identifies which - // encoding produced it. + // The bytes stay out of the message: the length identifies which + // encoding produced it, and the caller logs the error. bail!("pane {id} input is not valid UTF-8 ({} bytes)", data.len()); }; self.link.send(HubClientMessage::Input { pane: id, data }) } - fn resize(&mut self, id: PaneId, rows: u16, cols: u16) { - if let Err(err) = self.link.send(HubClientMessage::Resize { + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result { + self.link.send(HubClientMessage::Resize { pane: id, rows, cols, - }) { - tracing::warn!(%err, pane = id, "could not resize a pane in the session"); - } + })?; + Ok(ResizeOutcome::Pending) } fn reorder(&mut self, order: &[PaneId]) { @@ -162,20 +156,18 @@ impl TerminalBackend for HubBackend { deadline_epoch, attempt, }), - // An attached client already knows who it is — the daemon told - // it when it subscribed, and `rewrite_requester` restates every - // `created` in that id space before it gets here. This names the - // browser-side hub connection, which is one hop in. + // The daemon already rewrote every `created` into this + // client's id space, so a Hello here names the browser-side + // hub connection — nothing this client needs. TerminalMessage::Event(HubServerMessage::Hello { .. }) => {} - // Deliberately dropped: a browser's zoom is not this client's. - // The TUI has a zoom of its own that means something else — it - // follows *its* active pane and takes the body from the diff - // viewer with it (`TerminalFullscreen::Zoom`), so letting a page - // drive it would let someone at a browser hide a panel here. - // The panes are shared; what fills a screen is each screen's. + // Deliberately dropped: the TUI's zoom follows *its* active + // pane and takes the diff viewer with it, so letting a browser + // page drive it would let someone at a browser hide a panel + // here. The panes are shared; what fills a screen is each + // screen's. TerminalMessage::Event(HubServerMessage::Zoomed { .. }) => {} - // Refusals do not come this way — they are not about a pane, so - // the client keeps them on the queue that reaches its notices. + // Refusals are not about a pane; the client keeps them on the + // queue that reaches its notices. TerminalMessage::Event(HubServerMessage::Error { message }) => { tracing::warn!(%message, "unexpected terminal refusal on a pane inbox"); } diff --git a/src/backend/hub_tests.rs b/src/backend/hub_tests.rs index fdad934b..b30e6c68 100644 --- a/src/backend/hub_tests.rs +++ b/src/backend/hub_tests.rs @@ -43,7 +43,9 @@ impl Wired { } fn deliver(&self, event: HubServerMessage) { - self.router.deliver(REPO, TerminalMessage::Event(event)); + self.router + .deliver(REPO, TerminalMessage::Event(event)) + .expect("terminal inbox accepts the event"); } } @@ -170,15 +172,18 @@ fn a_pane_another_client_opened_arrives_without_claiming_the_focus() { #[test] fn output_and_exits_come_through_as_they_are() { let mut wired = wired(); - wired.router.deliver( - REPO, - TerminalMessage::Output { - pane: 1, - // Not valid UTF-8: a multi-byte sequence split across reads is - // routine, and the emulator is what reassembles it. - data: vec![0xe2, 0x94], - }, - ); + wired + .router + .deliver( + REPO, + TerminalMessage::Output { + pane: 1, + // Not valid UTF-8: a multi-byte sequence split across reads is + // routine, and the emulator is what reassembles it. + data: vec![0xe2, 0x94], + }, + ) + .expect("terminal inbox accepts the output"); wired.deliver(HubServerMessage::Exited { pane: 1 }); let events = wired.backend.drain_events(); diff --git a/src/backend/identity.rs b/src/backend/identity.rs index c38f9595..e8deb8c1 100644 --- a/src/backend/identity.rs +++ b/src/backend/identity.rs @@ -12,19 +12,11 @@ pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; /// Env var naming the directory a hub's plugins put their runtime sockets in. /// -/// A plugin process belongs to one [`TerminalHub`](crate::session::terminal), -/// and a hub is per repository — so a session with six projects runs six of -/// each plugin. A plugin that picks one fixed socket path is therefore not -/// wrong about its own instance but about how many there are: the first binds, -/// the rest find the address taken and run without their socket, and a helper -/// inside a pane reaches whichever instance won rather than the one watching -/// it. -/// -/// Both sides derive this from the hub's working directory, so nothing has to -/// be handed from the plugin spawn to the pane spawn: they compute the same -/// directory from the same input. The pane's children inherit it exactly as -/// they inherit [`PANE_TOKEN_ENV`], which is what lets a provider's hook find -/// the instance that is watching the pane it runs in. +/// A hub is per repository, so a session with six projects runs six of each +/// plugin; a plugin that picks one fixed socket path would collide. Both sides +/// derive this from the hub's working directory, so the plugin spawn and the +/// pane spawn agree without either being told by the other — and a provider's +/// hook finds the instance watching the pane it runs in via inheritance. pub const PLUGIN_RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// The directory the plugins of the hub rooted at `cwd` use for their sockets. @@ -32,10 +24,9 @@ pub const PLUGIN_RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// `None` when there is nowhere to put one, which leaves a plugin on whatever /// default it had — degraded exactly as it is today rather than refused. /// -/// The hub's path is hashed rather than spelled out. AF_UNIX paths are capped +/// The hub's path is hashed rather than spelled out: AF_UNIX paths are capped /// near 107 bytes and a repository path can be most of that on its own, so a -/// fixed-width digest is what keeps the socket bindable; it also keeps a -/// directory name from carrying where someone's code lives. +/// fixed-width digest is what keeps the socket bindable. pub fn plugin_runtime_dir(cwd: &std::path::Path) -> Option { let base = match std::env::var_os("XDG_RUNTIME_DIR").filter(|d| !d.is_empty()) { Some(dir) => std::path::PathBuf::from(dir).join("nightcrow"), @@ -64,12 +55,10 @@ const TOKEN_BYTES: usize = 16; /// Opaque name for a pane slot, stable for as long as the slot exists. /// -/// [`PaneId`](super::PaneId) cannot serve this purpose outside the process that -/// owns the panes: it is a per-backend counter that restarts at 1 whenever a -/// backend is rebuilt, so the same number means different panes across two -/// runs. The token is random instead, and it deliberately outlives the process -/// occupying the slot — an observer tracking a slot keeps its state when the -/// slot's process is replaced. +/// [`PaneId`](super::PaneId) cannot serve this outside the owning process: it +/// is a per-backend counter that restarts whenever a backend is rebuilt. The +/// token is random instead and deliberately outlives the process occupying the +/// slot, so an observer tracking a slot keeps its state across a replacement. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct PaneToken(String); @@ -94,13 +83,9 @@ impl PaneToken { } } -/// Which spawn of a pane slot something refers to. -/// -/// Starts at [`FIRST_GENERATION`] and rises every time the slot's process is -/// replaced. An out-of-process observer decides what to do asynchronously, so -/// by the time it asks for something the process it watched may already be -/// gone; carrying the generation is what makes that detectable instead of -/// letting a decision about one process land on its successor. +/// Which spawn of a pane slot something refers to: an out-of-process observer +/// decides asynchronously, so carrying the generation makes acting on an +/// already-replaced process detectable. pub type PaneGeneration = u32; pub const FIRST_GENERATION: PaneGeneration = 1; diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 1e1126e1..6fc407ef 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -11,6 +11,14 @@ use anyhow::Result; pub type PaneId = u32; +/// Whether a successful resize call has already changed the PTY or only queued +/// a request whose eventual size will arrive as [`BackendEvent::Resized`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResizeOutcome { + Applied, + Pending, +} + #[derive(Debug)] pub enum BackendEvent { /// A pane now exists. Reported rather than returned from `create_pane` @@ -34,21 +42,17 @@ pub enum BackendEvent { Exited { pane: PaneId, }, - /// The size a pane's PTY is now set to. - /// - /// Only a backend serving a shared session reports this, and it is not - /// necessarily what this side asked for: the size belongs to whichever - /// client owns the sizing. An emulator has to wrap where the child does. + /// The size a pane's PTY is now set to. Only a shared-session backend + /// reports this, and it is not necessarily what this side asked for: the + /// size belongs to whichever client owns the sizing. Resized { pane: PaneId, rows: u16, cols: u16, }, - /// The canonical order of the panes. - /// - /// Only a backend serving a shared session reports this: the order is part - /// of what the session owns. Ids this side does not know are ignored and - /// panes the order omits keep their place. + /// The canonical order of the panes. Only a shared-session backend reports + /// this: the order is part of what the session owns. Unknown ids are + /// ignored; panes the order omits keep their place. Reordered { order: Vec, }, @@ -60,11 +64,8 @@ pub enum BackendEvent { owned: bool, }, /// What a plugin driving `pane` reports about getting it running again. - /// - /// Only a backend serving a shared session reports this: the plugins run - /// beside the session's panes, not beside this client. - /// A plugin reported that this pane wants attention. Client-local from - /// here: it raises the project tab's unread marker. + /// Only a shared-session backend reports this: the plugins run beside the + /// session's panes, not beside this client. Attention { pane: PaneId, }, @@ -90,7 +91,7 @@ pub trait TerminalBackend { fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>; fn destroy_pane(&mut self, id: PaneId); fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; - fn resize(&mut self, id: PaneId, rows: u16, cols: u16); + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result; fn drain_events(&mut self) -> Vec; /// Ask for the panes to be put in this order. diff --git a/src/backend/pty.rs b/src/backend/pty.rs index 2677fe80..527b2194 100644 --- a/src/backend/pty.rs +++ b/src/backend/pty.rs @@ -1,5 +1,5 @@ use super::slot::{PaneSlot, PaneSlots}; -use super::{BackendEvent, PaneId, TerminalBackend}; +use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend}; use crate::config::ShellConfig; use crate::platform::threading::try_timed_join; use anyhow::Result; @@ -35,13 +35,11 @@ pub(super) enum PtyEvent { Exited, } -/// How far a pane has moved through its shutdown. -/// -/// The two end signals are not interchangeable. EOF on the master is final. -/// The child's death is not: on Windows `ClosePseudoConsole` only runs when -/// the master is dropped, so `read()` never returns EOF and the child's exit -/// is the *only* signal a pane gets. `Draining` holds the exit back until the -/// channel is dry. +/// How far a pane has moved through its shutdown. The two end signals are +/// not interchangeable: EOF on the master is final, but a child's death is +/// not — on Windows `ClosePseudoConsole` only runs when the master is dropped, +/// so `read()` never returns EOF and the child's exit is the *only* signal a +/// pane gets. `Draining` holds the exit back until the channel is dry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ExitPhase { /// No exit signal seen. @@ -53,9 +51,8 @@ pub(super) enum ExitPhase { } pub(super) struct PtyPane { - // master/writer are wrapped in Option so `Drop` can release them - // before joining the reader thread — the reader blocks in `read()` - // and only unblocks when both sides of the PTY are closed. + // Option wrapping so `Drop` releases master/writer before joining the + // reader thread, which only unblocks when both PTY sides close. pub(super) master: Option>, pub(super) writer: Option>, pub(super) killer: Box, @@ -69,17 +66,13 @@ impl Drop for PtyPane { fn drop(&mut self) { // Best-effort kill: the child may already be gone. let _ = self.killer.kill(); - // Drop writer/master so the reader's blocked `read()` returns EOF - // and the thread exits. Without this, joining the reader would - // hang. + // Drop writer/master so the reader's blocked `read()` returns EOF; + // without this, joining the reader would hang. self.writer.take(); self.master.take(); - // Bounded join so closing a pane cannot leave reader/wait threads - // alive holding fds against the (possibly killed) child. If a - // daemonized grandchild kept the slave fd open, the reader's - // `read()` won't return EOF; detach in that case rather than - // freezing the close. We're inside drop, so a panic in either - // thread is logged rather than propagated. + // Bounded join: if a daemonized grandchild kept the slave fd open the + // reader's `read()` never returns EOF — detach rather than freeze the + // close. Inside drop, so a thread panic is logged, not propagated. if let Some(h) = self.reader_handle.take() { try_timed_join(h, PTY_REAP_TIMEOUT); } @@ -90,17 +83,14 @@ impl Drop for PtyPane { } pub struct PtyBackend { - // BTreeMap (not HashMap) so per-frame event drain visits panes in - // PaneId order — IDs are monotonic, so this matches creation order - // and stays deterministic across runs. + // BTreeMap so per-frame drain visits panes in PaneId order — ids are + // monotonic, keeping the drain deterministic across runs. pub(super) panes: BTreeMap, - /// Slot bookkeeping — identity, launch, idle clock — kept beside `panes` - /// rather than inside `PtyPane` because a relaunch replaces the `PtyPane` - /// while the slot has to survive it. + /// Slot bookkeeping kept beside `panes` rather than inside `PtyPane` + /// because a relaunch replaces the pane while the slot survives it. pub(super) slots: PaneSlots, pub(super) next_id: PaneId, - // Each new pane spawns the shell here so its cwd matches the repo - // nightcrow is tracking. + // Spawned shell cwd must match the repo nightcrow tracks. pub(super) cwd: PathBuf, /// Panes created since the last drain, waiting to be reported. /// @@ -139,20 +129,15 @@ impl PtyBackend { self.panes.contains_key(&id) } - /// Let go of a pane's process while keeping its slot. - /// - /// Splitting this out of `destroy_pane` is what makes waiting for a reset - /// affordable: a wait can run for hours, and holding the dead child's fds - /// and threads open for that long to preserve the token would be pure - /// waste. The slot is small, and it is the only part a relaunch needs. + /// Let go of a pane's process while keeping its slot, so a wait can run + /// for hours without holding the dead child's fds and threads open just + /// to preserve its token. The slot is the only part a relaunch needs. pub fn release_process(&mut self, id: PaneId) { self.panes.remove(&id); } - /// Drop a slot for good, retiring its token. - /// - /// Called when nothing more is expected of the pane — the wait was - /// abandoned, the pane was closed, or the session is going away. + /// Drop a slot for good, retiring its token: the wait was abandoned, the + /// pane was closed, or the session is going away. pub fn retire_slot(&mut self, id: PaneId) { self.slots.remove(id); } @@ -165,8 +150,6 @@ impl TerminalBackend for PtyBackend { // way, and this backend simply knows the answer before it queues it. // `requested` is always true — nothing else can create a pane here. self.created.push(BackendEvent::Created { - // A local backend has no name to give: whoever opened the pane knows - // what it is for. title: None, pane: id, rows, @@ -177,11 +160,9 @@ impl TerminalBackend for PtyBackend { } fn destroy_pane(&mut self, id: PaneId) { - // Removing the pane drops it, which runs PtyPane::drop: kill, - // release master/writer, join reader/wait threads. self.panes.remove(&id); - // The slot goes with it, retiring its token. A relaunch keeps the slot - // by going through `relaunch_pane` instead of destroy-then-open. + // A relaunch keeps the slot by going through `relaunch_pane` instead + // of destroy-then-open. self.slots.remove(id); } @@ -202,17 +183,22 @@ impl TerminalBackend for PtyBackend { Ok(()) } - fn resize(&mut self, id: PaneId, rows: u16, cols: u16) { - if let Some(pane) = self.panes.get_mut(&id) - && let Some(master) = pane.master.as_mut() - { - let _ = master.resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }); - } + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result { + let pane = self + .panes + .get_mut(&id) + .ok_or_else(|| anyhow::anyhow!("pane {id} not found"))?; + let master = pane + .master + .as_mut() + .ok_or_else(|| anyhow::anyhow!("pane {id} PTY master already released"))?; + master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + Ok(ResizeOutcome::Applied) } fn drain_events(&mut self) -> Vec { @@ -222,18 +208,14 @@ impl TerminalBackend for PtyBackend { // silently dropped, and where Exited could be reported twice. // // The reader thread emits all Output messages, then a single Exited as - // the last message before its sender drops. The mpsc channel preserves - // send order, so any Output enqueued before Exited has already been - // surfaced by an earlier iteration of the outer try_recv loop — no - // separate post-Exited drain is needed. - // - // `ChildExited` carries no such ordering — it can overtake output the - // child already wrote — so it only moves the pane to `Draining`. + // the last message before its sender drops, so the mpsc order means no + // separate post-Exited drain is needed. `ChildExited` carries no such + // ordering — it can overtake output already written — so it only moves + // the pane to `Draining`. // - // Each pane is drained up to PER_PANE_DRAIN_BUDGET events to keep - // one noisy pane (e.g. `yes | head -100000`) from starving its - // siblings within a single frame; whatever is left lands on the - // next tick. + // Each pane is drained up to PER_PANE_DRAIN_BUDGET events so one noisy + // pane (e.g. `yes | head -100000`) cannot starve its siblings within a + // frame; the rest lands on the next tick. // Ahead of any output: a pane has to exist before bytes can be routed // to it, and both can be queued before the same drain. let mut events: Vec = std::mem::take(&mut self.created); @@ -256,7 +238,6 @@ impl TerminalBackend for PtyBackend { } } Ok(PtyEvent::Exited) => { - // EOF is final, so nothing is held back. if pane.exit != ExitPhase::Reported { pane.exit = ExitPhase::Reported; events.push(BackendEvent::Exited { pane: *id }); @@ -264,8 +245,8 @@ impl TerminalBackend for PtyBackend { break; } Err(_) => { - // Dry for now. For a dead child that is the cue to - // report — once the grace has let late output land. + // Dry: for a dead child past its drain grace, the cue + // to report. if let ExitPhase::Draining { since } = pane.exit && now.duration_since(since) >= EXIT_DRAIN_GRACE { @@ -282,9 +263,8 @@ impl TerminalBackend for PtyBackend { } } -// `PtyBackend` no longer needs an explicit Drop: `HashMap::drop` drops every -// pane, and `PtyPane::drop` handles kill+release+join. Leaving an empty -// Drop here would still work but would obscure that ownership. +// No explicit `Drop` on `PtyBackend` on purpose: the map drops every pane and +// `PtyPane::drop` handles kill+release+join — an empty Drop would obscure that. #[cfg(test)] #[path = "pty_tests.rs"] diff --git a/src/backend/pty_spawn.rs b/src/backend/pty_spawn.rs index a807b62c..774bf911 100644 --- a/src/backend/pty_spawn.rs +++ b/src/backend/pty_spawn.rs @@ -12,12 +12,9 @@ use std::thread; use std::time::Instant; impl PtyBackend { - /// Open a pane and say which one it is. - /// - /// The trait reports panes as events, because a backend serving a shared - /// session cannot answer on the spot. This one can, and the terminal hub — - /// which owns a `PtyBackend` outright rather than through the trait — needs - /// the id to register the pane before anything else happens to it. + /// Open a pane and say which one it is. Returns the id directly — unlike + /// the trait — because the terminal hub owns a `PtyBackend` outright and + /// needs the id to register the pane before anything else happens to it. pub fn open_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result { let identity = PaneIdentity::new()?; let launch = PaneLaunch { @@ -26,16 +23,11 @@ impl PtyBackend { self.spawn_pane(rows, cols, command, identity, launch) } - /// Replace an exited pane's process, keeping the slot it ran in. - /// - /// A new `PaneId` is unavoidable: ids are monotonic and every client treats - /// `Exited` as final for one. The slot's token is what carries over, so an - /// observer that has been tracking this pane keeps its place, and the - /// generation moves so decisions made about the old process cannot land on - /// the new one. - /// - /// The composed command line is checked before anything is torn down, so a - /// refused relaunch leaves the pane exactly as it was. + /// Replace an exited pane's process, keeping the slot it ran in. A new + /// `PaneId` is unavoidable: ids are monotonic and every client treats + /// `Exited` as final. The slot's token carries over, so an observer keeps + /// its place; the generation moves so decisions about the old process + /// cannot land on the new one. pub fn relaunch_pane( &mut self, id: PaneId, @@ -55,13 +47,14 @@ impl PtyBackend { identity.advance(); // Retire the old process first: two children writing one slot's PTY // would interleave, and the reader thread has to be let go before the - // replacement's is started. + // replacement's is started. The composed line was checked above, so a + // refused relaunch never tears anything down. self.panes.remove(&id); self.slots.remove(id); - // The retained launch stays the *original* invocation. Carrying the - // composed line forward instead would accumulate resume arguments on - // every further relaunch. + // The retained launch stays the *original* invocation; carrying the + // composed line forward would accumulate resume arguments on every + // further relaunch. self.spawn_pane(rows, cols, Some(line.as_str()), identity, launch) } @@ -73,8 +66,8 @@ impl PtyBackend { identity: PaneIdentity, launch: PaneLaunch, ) -> Result { - // Reserve the next id only after every fallible PTY/spawn step succeeds, - // so a failure here does not consume an id slot. + // Reserve the next id only after every fallible PTY/spawn step + // succeeds, so a failure here does not consume an id slot. let pty_system = NativePtySystem::default(); let pair = pty_system.openpty(PtySize { rows, @@ -85,11 +78,9 @@ impl PtyBackend { let shell = self.shell.resolved_program(); let mut cmd = CommandBuilder::new(&shell); - // A reserved startup command runs through the shell's configured args: - // the command text is passed as a single argv item, so the shell — - // not us — handles its quoting/word-splitting. This avoids the race - // of spawning a shell and later injecting `command\r`, and avoids any - // string interpolation into a wrapper on our side. + // A reserved startup command goes through the shell's configured args + // as a single argv item, so the shell handles its quoting. This avoids + // the race of spawning a shell and later injecting `command\r`. if let Some(command) = command { for arg in self.shell.command_args() { cmd.arg(arg); @@ -101,10 +92,10 @@ impl PtyBackend { // provider's own helper processes inherit it — that inheritance is what // lets an out-of-process observer name the pane an event came from. cmd.env(PANE_TOKEN_ENV, identity.token.as_str()); - // Alongside the token and inherited the same way: a provider's hook is - // told which pane it is in *and* where that pane's plugins listen. The - // plugin spawn derives this from the same hub path, so the two agree - // without either being told by the other. + // Alongside the token: a provider's hook is told which pane it is in + // *and* where that pane's plugins listen. The plugin spawn derives + // this from the same hub path, so the two agree without either being + // told by the other. if let Some(dir) = crate::backend::identity::plugin_runtime_dir(std::path::Path::new(&self.cwd)) { diff --git a/src/backend/pty_tests/lifecycle.rs b/src/backend/pty_tests/lifecycle.rs index 2b484752..20b66206 100644 --- a/src/backend/pty_tests/lifecycle.rs +++ b/src/backend/pty_tests/lifecycle.rs @@ -9,6 +9,15 @@ fn pty_backend_create_and_destroy_pane() { assert!(!backend.panes.contains_key(&id)); } +#[test] +fn resizing_an_unknown_pane_is_reported() { + let mut backend = PtyBackend::new(".", ShellConfig::default()); + + let error = backend.resize(999, 24, 80).expect_err("unknown pane"); + + assert!(error.to_string().contains("pane 999 not found")); +} + #[test] fn a_pane_whose_shell_exits_reports_it() { let mut backend = PtyBackend::new(".", ShellConfig::default()); diff --git a/src/backend/slot.rs b/src/backend/slot.rs index 2296beaa..db119f25 100644 --- a/src/backend/slot.rs +++ b/src/backend/slot.rs @@ -89,23 +89,19 @@ const MAX_RESUME_ARGS: usize = 6; /// Longest single resume argument. Comfortably past a UUID or a session name. const MAX_RESUME_ARG_LEN: usize = 256; -/// Characters a resume argument may consist of. -/// -/// Deliberately narrower than "anything the shell can be made to swallow": the -/// argument is appended to a command line that a login shell parses, so a value -/// carrying a space, quote, backtick, `$`, or `;` is refused outright rather -/// than escaped differently by every supported shell. +/// Characters a resume argument may consist of. Deliberately narrower than +/// "anything the shell can be made to swallow": the argument lands on a command +/// line a login shell parses, so a value carrying a space, quote, backtick, +/// `$`, or `;` is refused outright rather than escaped per shell. fn is_safe_arg_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '@' | '+') } -/// Build the command line for a relaunch. -/// -/// `allowed_flags` is the plugin's declared list from config. The first token -/// (flag or subcommand) and every option-like token must appear there. Values -/// following an approved control token remain provider data such as a session -/// id. This lets the core refuse an unapproved relaunch mode without knowing a -/// particular CLI's grammar. +/// Build the command line for a relaunch. The first token and every +/// option-like token must be in `allowed_flags` (the plugin's declared list +/// from config); values following an approved control token stay provider +/// data. This lets the core refuse an unapproved relaunch mode without +/// knowing a particular CLI's grammar. pub fn resume_command_line( base: Option<&str>, resume_args: &[String], diff --git a/src/config.rs b/src/config.rs index 43eb9c69..df65e58b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,8 +25,7 @@ pub use web::{WebViewerConfig, ensure_web_viewer_password}; /// range, so every startup pane is reachable by a direct key. pub const MAX_STARTUP_COMMANDS: usize = 8; -/// Upper bound on `[[plugin]]` entries. Tracks `MAX_STARTUP_COMMANDS` rather -/// than being independently generous. +/// Upper bound on `[[plugin]]` entries. Tracks `MAX_STARTUP_COMMANDS`. pub const MAX_PLUGINS: usize = 8; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -144,24 +143,21 @@ pub fn validate_config(cfg: &Config) -> Result<()> { "log.commit_log_prefetch_threshold must be between 1 and log.commit_log_page_size" ); // `max_size_mb == 0` would make SizeRollingAppender rotate on every - // write (and even degenerate to creating a new file per write call), - // so disallow it. The upper bound is a sanity ceiling that still - // allows hours of trace logging at high volume. + // write (even degenerate to a new file per write call), so disallow it; + // the upper bound is a sanity ceiling. anyhow::ensure!( (1..=10_000).contains(&cfg.log.max_size_mb), "log.max_size_mb must be between 1 and 10000" ); // `max_days == 0` is the documented "keep forever" sentinel and is - // intentionally accepted; only the upper bound is sanity-checked so a - // typo in years-vs-days doesn't silently produce log retention that - // exceeds the host's life. + // intentionally accepted; only the upper bound is sanity-checked. anyhow::ensure!( cfg.log.max_days <= 3650, "log.max_days must be at most 3650 (10 years); 0 = keep forever" ); - // `0` is the "never expires" sentinel, as it is for `log.max_days`. The - // ceiling is 10 years: anything past it is a unit mix-up, and the value is - // multiplied into seconds, which is where an unbounded one would overflow. + // `0` is the "never expires" sentinel, as for `log.max_days`; the ceiling + // catches a unit mix-up, and the value is multiplied into seconds, where + // an unbounded one would overflow. anyhow::ensure!( cfg.web_viewer.session_ttl_hours <= 87_600, "web_viewer.session_ttl_hours must be at most 87600 (10 years); 0 = never expires" @@ -202,10 +198,10 @@ pub fn validate_config(cfg: &Config) -> Result<()> { } /// Merge config `[[startup_command]]` entries with CLI `--exec` commands into -/// the final ordered list of panes to open at launch. Config entries come -/// first, then CLI commands (labelled by their command text). The combined -/// count is held to `MAX_STARTUP_COMMANDS`, and empty `--exec` values are -/// rejected — config entries were already validated by `validate_config`. +/// the final ordered list of panes to open at launch: config entries first, +/// then CLI commands, the combined count held to `MAX_STARTUP_COMMANDS`. +/// Empty `--exec` values are rejected here; config entries were already +/// validated by `validate_config`. pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result> { merge_startup_commands(&cfg.startup_commands, cli_exec) } @@ -213,9 +209,8 @@ pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result`. +/// run inside tmux), the Ctrl chords an inner Claude Code pane reserves, +/// terminal flow control (`Ctrl+Q`/`Ctrl+S`), and shell signals +/// (`Ctrl+C/D/Z`). Its only collision is `Ctrl+F` as forward-char / +/// page-forward, which users almost always reach via arrow keys / PageDown; +/// when needed it stays reachable via ``. pub(super) const DEFAULT_LEADER: &str = "ctrl+f"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,10 +109,11 @@ impl Default for InputConfig { } /// Parse a leader chord string (e.g. `"ctrl+b"`) into a `KeyEvent`. -/// Only `ctrl+` chords are accepted. The chord must be a key -/// that `encode_key` can turn into literal bytes (so `` can pass the -/// leader through to the PTY) and must NOT collide with a no-prefix reserved -/// key. F-keys, Shift+arrows, and Shift+PgUp/PgDn are reserved and rejected. +/// +/// Only `ctrl+` chords are accepted. The chord must be a key +/// `encode_key` can turn into literal bytes (so `` can pass the leader +/// through to the PTY) and must not collide with a reserved key; F-keys, +/// Shift+arrows, and Shift+PgUp/PgDn are reserved and rejected. pub fn parse_leader(spec: &str) -> Result { let normalized = spec.trim().to_ascii_lowercase(); let rest = normalized.strip_prefix("ctrl+").ok_or_else(|| { @@ -144,10 +145,9 @@ pub fn parse_leader(spec: &str) -> Result { and Ctrl+M as Enter, so this leader would never be recognized" ); // Restricting to letters guarantees `` literal pass-through works: - // `encode_key` maps Ctrl+A..Ctrl+Z to control bytes 1..26. Digits and - // punctuation (e.g. ctrl+1) have no single-control-byte encoding, so - // encode_key would send the literal char instead and the pass-through - // would break — hence they are rejected above. + // `encode_key` maps Ctrl+A..Ctrl+Z to control bytes 1..26, while digits + // and punctuation have no single-control-byte encoding, so the + // pass-through would break — hence they are rejected above. Ok(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) } diff --git a/src/config/plugin.rs b/src/config/plugin.rs index f603a760..84871634 100644 --- a/src/config/plugin.rs +++ b/src/config/plugin.rs @@ -23,26 +23,26 @@ pub struct PluginConfig { /// Flags this plugin may append when relaunching a pane's command. /// /// Empty by default, which refuses every relaunch that passes a flag. The - /// core has no idea what any CLI's flags mean, so the decision of which - /// ones a plugin may add is the user's: a flag that is not listed here - /// cannot be smuggled into the pane's command line, which is what keeps a - /// plugin from quietly weakening a CLI's permission posture. + /// core has no idea what any CLI's flags mean, so the user decides: a flag + /// not listed here cannot be smuggled into the pane's command line, which + /// is what keeps a plugin from quietly weakening a CLI's permission + /// posture. #[serde(default)] pub allowed_resume_flags: Vec, /// Off unless explicitly turned on. #[serde(default)] pub enabled: bool, /// Whether this plugin may be given a pane no `[[startup_command]]` named - /// it in, when a process *inside* that pane reports to it quoting the pane's - /// own token. + /// it in, when a process *inside* that pane reports to it quoting the + /// pane's own token. /// - /// Off by default, so an existing config keeps the property that the opt-in - /// list is the whole of what a plugin can see. Turning it on trades that for - /// a narrower one: a pane is reachable once something running in it has - /// spoken to the plugin, which a plain shell never does. The token is what - /// makes the difference — it is random, per pane, and only in that pane's - /// child environment, so it cannot be guessed from outside and the plugin is - /// never told which panes exist. + /// Off by default, so an existing config keeps the property that the + /// opt-in list is the whole of what a plugin can see. Turning it on trades + /// that for a narrower one: a pane becomes reachable once something + /// running in it has spoken to the plugin, which a plain shell never does. + /// The token makes the difference — random, per pane, only in that pane's + /// child environment, so it cannot be guessed from outside and the plugin + /// is never told which panes exist. #[serde(default)] pub watch_on_signal: bool, } diff --git a/src/config/web.rs b/src/config/web.rs index 58e43fb7..7176d36a 100644 --- a/src/config/web.rs +++ b/src/config/web.rs @@ -80,13 +80,10 @@ pub fn generate_password() -> Result { .collect()) } -/// Ensure the viewer has a login credential, generating and persisting -/// one when the config has none. A no-op when a `password` or `hashed_password` -/// is already set. Otherwise a random password is generated, written back into -/// the config file at `path` (creating it if absent, preserving any existing -/// content and comments), and stored on `cfg` so the running instance uses it. -/// Returns the freshly generated password so the caller can surface it to the -/// user, or `None` when a credential already existed. +/// Ensure the viewer has a login credential, generating and persisting one +/// into the config file at `path` (format-preserving, comments kept) when it +/// has none. Returns the freshly generated password for the caller to surface, +/// or `None` when a credential already existed. pub fn ensure_web_viewer_password( cfg: &mut super::Config, path: &std::path::Path, diff --git a/src/daemon/client.rs b/src/daemon/client.rs index 31f7a5ad..37c8b431 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -26,9 +26,8 @@ pub struct DaemonClient { incoming: Receiver, /// Terminal traffic, split per repository for the backends that drain it. terminals: Arc, - /// This connection's id at the daemon, from the handshake. Handed to each - /// repository's backend to tell a pane this client opened from one that - /// appeared because another client did. + /// This connection's id at the daemon, so backends can tell a pane this + /// client opened from one another client opened. client: u64, /// Cleared by the reader thread when the daemon goes away. A separate flag /// rather than the channel's disconnected state: reading that means calling @@ -65,10 +64,9 @@ impl DaemonClient { let incoming = read_routed(&mut reader, &terminals)? .context("the daemon closed the connection during the handshake")?; let Incoming::Control(message) = incoming else { - // Terminal traffic starts before the handshake answer, because - // the daemon subscribes this client's repositories the moment it - // connects. Already filed with the router by `read_routed`, - // which is where the panes it describes will be looked for. + // Terminal traffic starts before the handshake answer — the + // daemon subscribes this client the moment it connects — and + // `read_routed` has already filed it with the router. continue; }; match message { @@ -81,27 +79,24 @@ impl DaemonClient { } break client; } - // The daemon volunteers the repository set on attach, so it can - // arrive before the handshake answer. Kept rather than dropped: - // it is the state this client is about to render. + // The daemon volunteers the repository set on attach, which can + // arrive before the handshake answer. Kept: it is the state + // this client is about to render. other @ (ServerMessage::Repos { .. } | ServerMessage::Terminal { .. }) => { queued.push(other) } ServerMessage::Error { message } => bail!("daemon refused the attach: {message}"), - // Nobody has asked for a reload yet — this client has not - // finished attaching. Dropped rather than queued: it would be an - // answer to a request that was never made. + // Dropped: an answer to a request this client has not made yet. ServerMessage::Reloaded { .. } => { tracing::debug!("attach: a reload answer arrived before the handshake"); } } }; // Best-effort: macOS rejects the option on a socket whose peer has - // already gone, which is exactly the race of attaching as the daemon - // stops — and failing the attach over it would report a platform quirk - // instead of the plain fact that the daemon went away. A timeout left in - // place is harmless: the reader loop treats one as "still waiting" - // rather than as a disconnect. + // already gone — exactly the race of attaching as the daemon stops — + // and failing the attach over it would report a platform quirk instead + // of the plain fact that the daemon went away. A leftover timeout is + // harmless: the reader loop treats one as "still waiting". if let Err(err) = reader.set_read_timeout(None) { tracing::debug!(%err, "could not clear the handshake timeout"); } @@ -141,9 +136,8 @@ impl DaemonClient { ) } - /// Drop the terminal inboxes of repositories that are no longer open. Called - /// with each set the daemon reports, which is also when the tabs are - /// reconciled. + /// Drop the terminal inboxes of repositories that are no longer open. + /// Called with each set the daemon reports, when the tabs are reconciled. pub fn retain_repos(&self, open: &[String]) { self.terminals.retain(open); } diff --git a/src/daemon/clients.rs b/src/daemon/clients.rs index be88e63d..dd8e336c 100644 --- a/src/daemon/clients.rs +++ b/src/daemon/clients.rs @@ -123,20 +123,15 @@ impl AttachedClients { /// Send `frame` to every attached client, and count them told: nobody is /// left owed a set by a broadcast that just reached them. /// - /// The two are one act, under one lock hold, because a client that attaches - /// between them was *not* a recipient — clearing its flag afterwards would - /// leave it waiting for a set the watcher has already recorded as sent, and - /// with no further change to the session nothing would ever send one. A - /// client that attaches after this returns is not in the list, keeps its - /// flag, and is served on the next pass. + /// The two are one act, under one lock hold, because a client that + /// attaches between them was *not* a recipient — clearing its flag + /// afterwards would leave it waiting for a set the watcher has already + /// recorded as sent. A client that attaches after this returns is not in + /// the list, keeps its flag, and is served on the next pass. /// - /// The served set is the only thing every client is sent at once — a - /// repository's pane output goes per subscriber — so there is no broadcast - /// this does not settle. - /// - /// Never blocks: the lock is held while queueing, and a blocking send would - /// let one stalled client stop the session for all the others. A client whose - /// queue is full is cut off instead — for itself alone. + /// Never blocks: the lock is held while queueing, and a blocking send + /// would let one stalled client stop the session for all the others. A + /// client whose queue is full is cut off — for itself alone. pub fn broadcast(&self, frame: Frame) { let mut clients = self.inner.lock().expect("attached clients poisoned"); clients.retain_mut(|client| { @@ -145,9 +140,9 @@ impl AttachedClients { }); } - /// Note that `id` is waiting to be told the session's shape, for the watcher - /// to answer on its next pass. Unknown ids are ignored: the client detached - /// between asking and this. + /// Note that `id` is waiting to be told the session's shape, for the + /// watcher to answer on its next pass. Unknown ids are ignored: the client + /// detached between asking and this. pub fn owe_set(&self, id: u64) { let mut clients = self.inner.lock().expect("attached clients poisoned"); if let Some(client) = clients.iter_mut().find(|client| client.id == id) { diff --git a/src/daemon/detach.rs b/src/daemon/detach.rs index d47dc4a1..6c74169e 100644 --- a/src/daemon/detach.rs +++ b/src/daemon/detach.rs @@ -1,12 +1,10 @@ -//! Putting the daemon into the background. Re-exec rather than fork: by the -//! time this is decided the process has not started its worker threads yet, but -//! the pattern is the trap either way — `fork` in a threaded process gives the -//! child one thread and every lock in whatever state it was in. Spawning a fresh -//! copy of this binary has no such state to inherit. +//! Putting the daemon into the background. Re-exec rather than fork: `fork` in +//! a threaded process gives the child one thread and every lock in whatever +//! state it was in; spawning a fresh copy of this binary has no such state to +//! inherit. //! //! The child gets its own session (`setsid`), so closing the terminal that -//! started it does not send it SIGHUP along with the shell's other children. -//! That is the whole difference from `&`. +//! started it does not send it SIGHUP — the whole difference from `&`. use anyhow::{Context, Result}; use std::process::{Command, Stdio}; @@ -22,11 +20,10 @@ pub fn is_detached_child() -> bool { /// The rule the marker carries, split from reading it. /// -/// Reading the environment inside the rule made the test answer for the -/// machine it ran on: a suite started from inside a nightcrow pane inherits -/// the marker from the daemon that spawned the pane, and the foreground case -/// then failed while saying nothing about the rule. Presence is what counts — -/// the child is spawned with `"1"`, but an empty value is still a marker. +/// Split out because a suite started from inside a nightcrow pane inherits the +/// marker from the daemon that spawned the pane, and the foreground case then +/// failed while saying nothing about the rule. Presence is what counts — the +/// child is spawned with `"1"`, but an empty value is still a marker. fn marker_says_detached(marker: Option<&std::ffi::OsStr>) -> bool { marker.is_some() } @@ -54,11 +51,9 @@ fn child_args(args: impl Iterator) -> Vec(writer: &mut W, frame: &Frame) -> Result<()> { if frame.payload.len() > MAX_FRAME_BYTES { bail!( @@ -111,8 +101,8 @@ pub fn write_frame(writer: &mut W, frame: &Frame) -> Result<()> { ); } // Built as one buffer and written once: a header written separately can - // reach the peer as its own packet, and a writer that dies between the two - // leaves a header with no body for the reader to block on. + // reach the peer as its own packet, leaving a reader blocked on a body + // that never comes. let mut out = Vec::with_capacity(5 + frame.payload.len()); out.push(frame.kind as u8); out.extend_from_slice(&(frame.payload.len() as u32).to_be_bytes()); @@ -121,11 +111,9 @@ pub fn write_frame(writer: &mut W, frame: &Frame) -> Result<()> { Ok(()) } -/// Read one frame, or `None` at a clean end of stream. -/// -/// `None` means the peer closed between frames, which is how a client detaches; -/// an error means it closed *inside* one, which is a truncated message and not -/// something to resume from. +/// Read one frame, or `None` at a clean end of stream — which is how a client +/// detaches. An error means the peer closed *inside* a frame: a truncated +/// message, not something to resume from. pub fn read_frame(reader: &mut R) -> Result> { let mut header = [0u8; 5]; if !read_exact_or_eof(reader, &mut header)? { @@ -146,11 +134,9 @@ pub fn read_frame(reader: &mut R) -> Result> { } /// Fill `buf`, reporting whether the stream ended before the first byte. -/// -/// An end of stream part-way through is an error rather than a `false`: the -/// distinction the caller needs is "nothing more is coming" versus "a message -/// was cut in half", and collapsing them would let a truncated frame look like -/// a clean detach. +/// Part-way through is an error: "nothing more is coming" and "a message was +/// cut in half" must not collapse, or a truncated frame would look like a +/// clean detach. fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> Result { if buf.is_empty() { return Ok(true); diff --git a/src/daemon/lock.rs b/src/daemon/lock.rs index 91d5e60a..1547c2e1 100644 --- a/src/daemon/lock.rs +++ b/src/daemon/lock.rs @@ -1,14 +1,10 @@ //! The single-instance lock. Two daemons on one socket would each serve half //! the attaching clients, and the second to bind would displace the first. -//! Deciding which is running has to be exact, which rules out asking the socket: -//! a `connect` that succeeds does not prove a listener is alive — on macOS it -//! can succeed against a socket whose listener has closed, and the reset only -//! shows up on the next read. -//! -//! An advisory lock answers instead. The kernel holds it for as long as the -//! descriptor is open and releases it when the process ends — including a -//! `kill -9`, where no cleanup code of ours runs. So holding the lock means -//! "no other daemon is live" with no race and no timeout. +//! An advisory lock decides — not the socket, where a `connect` that succeeds +//! does not prove a listener is alive (macOS can succeed against a socket whose +//! listener has closed). The kernel holds the lock until the process ends — +//! including a `kill -9` — so holding it means "no other daemon is live" with +//! no race and no timeout. use anyhow::{Context, Result}; use std::fs::{File, OpenOptions, TryLockError}; @@ -71,9 +67,7 @@ pub(crate) enum Attempt { /// Read a lock failure. `Interrupted` earns its own arm because this process /// raises signals at itself — a stop signal is how the daemon is asked to shut -/// down. A signal landing on the thread inside the lock call returns EINTR, -/// which says nothing about who holds the lock; reported as a failure it would -/// refuse to start a daemon for no reason. +/// down — and EINTR says nothing about who holds the lock. /// /// std 가 EINTR 를 내부에서 재시도하는지는 문서화되어 있지 않다. /// 재시도한다면 이 arm 은 도달하지 않을 뿐 해가 없고, 재시도하지 @@ -81,10 +75,8 @@ pub(crate) enum Attempt { /// 기대는 대신 남겨 둔다. pub(crate) fn outcome_of(err: &TryLockError) -> Attempt { match err { - // 다른 daemon 이 쥐고 있다. 정상적인 부정 응답. TryLockError::WouldBlock => Attempt::Held, - // 시그널이 호출 중간에 도착해 락을 시도조차 못 했다. 누가 무엇을 - // 쥐고 있는지 아무 말도 하지 않으므로 다시 묻는 것만이 옳다. + // 시그널이 호출 중간에 도착해 락을 시도조차 못 했다. 다시 묻는 것만이 옳다. TryLockError::Error(err) if err.kind() == std::io::ErrorKind::Interrupted => { Attempt::Interrupted } @@ -93,14 +85,6 @@ pub(crate) fn outcome_of(err: &TryLockError) -> Attempt { } impl Drop for InstanceLock { - /// Release the lock before the descriptor closes. - /// - /// Closing does release it — but not synchronously. A lock on a freshly - /// opened descriptor a millisecond later can still see the lock held, - /// which showed up as a daemon refusing to start with "already running" - /// moments after the previous one had gone, roughly once in every few - /// hundred stop-and-start cycles. `unlock` releases before this returns, - /// so the next daemon's attempt cannot race the last one's exit. fn drop(&mut self) { // 닫힘만으로도 해제되지만 동기적이지 않다. 명시적 unlock 이 없으면 // 직전 daemon 이 사라진 직후의 재시작이 "이미 실행 중" 으로 거부되는 diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index ae53fa17..34ec02fe 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -38,21 +38,18 @@ pub enum ClientMessage { /// Paint the session in this accent, for every client and the browser. /// /// An index into the accent cycle rather than a "next" step: two clients - /// cycling at once would each advance from what they last saw and land - /// somewhere neither asked for. An index past the end wraps. + /// cycling at once would not agree on what "next" means. Wraps past the end. SetAccent { accent: usize, }, /// Re-read `config.toml` and apply the tables the session owns. /// - /// Carries nothing: the file is the request. Sending its contents would let - /// a client reconfigure the session from something it made up; this way the - /// daemon only acts on a file on its own disk that the user wrote. + /// Carries nothing: the daemon only acts on a file on its own disk that the + /// user wrote, never on contents a client could have made up. ReloadConfig, - /// Act on one repository's terminals. Carries the hub's own message rather - /// than a parallel set so the two definitions of "create a pane" cannot - /// drift. The repository rides along because one socket multiplexes every - /// open repository, where the browser opens a connection per repository. + /// Act on one repository's terminals. Carries the hub's own message so the + /// two definitions of "create a pane" cannot drift; the repository rides + /// along because one socket multiplexes every open repository. Terminal { repo: String, message: HubClientMessage, @@ -76,28 +73,23 @@ pub enum ServerMessage { client: u64, }, /// The repository set, sent in answer to a list, open, close, or reorder. - /// - /// Every mutation answers with the whole set rather than a delta: the set - /// is small, bounded by `MAX_PROJECTS`, and another client may have changed - /// it in between — a delta applied to a stale list silently diverges. + /// The whole set rather than a delta: another client may have changed it in + /// between, and a delta applied to a stale list silently diverges. Repos { repos: Vec, - /// The repository the session is focused on. `None` when nothing has - /// been focused yet. Carried with the set because the two change - /// together — opening a repository focuses it. + /// The focused repository, if any. Carried with the set because the + /// two change together — opening a repository focuses it. #[serde(default)] active: Option, - /// The accent the whole session paints in. Required, unlike `active`: - /// a default here would be a colour, and a daemon too old to send one - /// would have this client painting the session yellow and claiming that - /// was its choice. + /// The session's accent. Required, unlike `active`: a default would + /// misattribute an old daemon's silence as this client's choice. accent: usize, }, /// A request could not be carried out. The connection stays open: a refused /// request is an answer, not a protocol violation. Error { message: String }, /// A reload was carried out, described for the person who asked. Answered - /// to the asker alone — nothing a reload does is visible in what the other + /// to the asker alone: nothing a reload does is visible in what the other /// clients are looking at. Reloaded { /// One line for the client to show. Built by the session so a browser @@ -112,10 +104,9 @@ pub enum ServerMessage { }, } -/// One repository in the served set. Narrower than the browser's `RepoDto`: -/// an attaching client renders with the TUI's own widgets and reads git locally, -/// so it needs the identity and the path, not the display fields the web UI -/// derives. +/// One repository in the served set. Narrower than the browser's `RepoDto`: an +/// attaching client renders with the TUI's own widgets and reads git locally, +/// so the display fields the web UI derives would be dead weight. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepoSummary { /// Opaque catalog id, stable for the daemon's lifetime. @@ -124,12 +115,10 @@ pub struct RepoSummary { pub path: String, } -/// Bytes a repository's pane produced, and who they belong to. -/// -/// Carried in a [`FrameKind::Terminal`](super::frame::FrameKind::Terminal) -/// frame rather than as JSON: PTY output is not guaranteed valid UTF-8 — a -/// multi-byte sequence is routinely split across reads — so encoding it as -/// text would corrupt it before any emulator saw it. +/// Bytes a repository's pane produced, and who they belong to. Carried in a +/// [`FrameKind::Terminal`](super::frame::FrameKind::Terminal) frame rather than +/// as JSON: PTY output is not guaranteed valid UTF-8 — a multi-byte sequence is +/// routinely split across reads — and a text encoding would corrupt it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalOutput { pub repo: String, @@ -138,10 +127,8 @@ pub struct TerminalOutput { } impl TerminalOutput { - /// `[repo len][repo][pane id][bytes]`, with the id little-endian to match - /// the hub's own binary framing. - /// - /// Refuses repository ids longer than 255 bytes instead of truncating the + /// `[repo len][repo][pane id][bytes]`, little-endian to match the hub's own + /// binary framing. Refuses long repository ids rather than truncating the /// payload into a frame the receiver would misinterpret. pub fn encode(&self) -> Result> { let repo = self.repo.as_bytes(); @@ -161,10 +148,8 @@ impl TerminalOutput { } /// Read one back, or `None` when the header is truncated or its repository - /// id is not valid UTF-8. - /// - /// The daemon only encodes and the attaching client only decodes: output - /// travels one way. + /// id is not valid UTF-8. Only the attaching client decodes: output travels + /// one way. pub fn decode(bytes: &[u8]) -> Option { let (&len, rest) = bytes.split_first()?; let len = usize::from(len); diff --git a/src/daemon/requests.rs b/src/daemon/requests.rs index d24b7be5..d4881270 100644 --- a/src/daemon/requests.rs +++ b/src/daemon/requests.rs @@ -1,8 +1,7 @@ -//! Carrying out one attached client's requests. A request is either a question -//! — answered to the asker — or a change to the session, which is not answered -//! here at all: every client is looking at the same session, so the watcher -//! tells them all from one record of what they have been told. Refusals go to -//! the asker alone; a client must not flash an error for somebody else's typo. +//! Carrying out one attached client's requests. Refusals go to the asker +//! alone; a client must not flash an error for somebody else's typo. State +//! changes are not answered here at all — the watcher tells every client from +//! one record of what they have been told. use super::frame::{FrameKind, encode_server, read_frame}; use super::protocol::{ClientMessage, ServerMessage, version}; @@ -15,9 +14,8 @@ use std::sync::Arc; /// Read requests from one client until it detaches. pub(super) fn read_requests(mut stream: UnixStream, id: u64, session: &Session) -> Result<()> { while let Some(frame) = read_frame(&mut stream)? { - // Terminal frames arrive once panes are shared; until then a client has - // no pane to write to, and a frame kind with no handler is dropped - // rather than closing the connection over it. + // Terminal frames arrive only once panes are shared; a frame kind with + // no handler is dropped rather than closing the connection over it. if frame.kind != FrameKind::Control { tracing::debug!("daemon: ignoring a terminal frame before panes are shared"); continue; @@ -37,13 +35,10 @@ pub(super) fn read_requests(mut stream: UnixStream, id: u64, session: &Session) Ok(()) } -/// Carry out one request against the served set. -/// -/// A state change is not answered here at all: every attached client is looking -/// at the same session, and the one that asked has no more claim on the result -/// than the others — so the watcher tells them all, from one record of what they -/// have been told. Refusals are addressed to the asker alone, since a client -/// must not flash an error for somebody else's typo. +/// Carry out one request against the served set. A state change is not +/// answered here: the watcher tells every client, from one record of what they +/// have been told (see `watch::watch`). Refusals are addressed to the asker +/// alone. fn handle(message: ClientMessage, id: u64, session: &Session) { let state = &session.state; match message { @@ -55,19 +50,17 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { client: id, } } else { - // Reported, not refused. The two ship in one binary, so a - // mismatch means two builds are running at once — worth saying - // plainly rather than failing with a decode error later. + // Reported, not refused: the two ship in one binary, so a + // mismatch means two builds are running at once. ServerMessage::Error { message: format!("client is {client}, daemon is {daemon}"), } }; session.clients.send_to(id, encode_reply(&reply)); } - // Answered to the asker alone — nothing changed, so there is nothing to - // tell the others — but not from here. The set is sent from one place so - // a client's frames arrive in the order the session changed (see - // `watch::watch`); this records that the asker is owed one and wakes it. + // Answered to the asker alone (nothing changed), but not from here — + // the set is sent from one place, in session-change order. This records + // that the asker is owed one and wakes the watcher. ClientMessage::ListRepos => { session.clients.owe_set(id); changed(session); @@ -90,10 +83,9 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { if session::focus_repo(state, &repo).is_ok() { changed(session); } else { - // The only way to name a repository the session does not have is - // to have raced a close on another client. Answered rather than - // dropped, because the asker is waiting to see that tab come - // forward and never will. + // Only way to name an unknown repository is to have raced a + // close on another client. Answered because the asker is + // waiting to see that tab come forward. refuse(id, session, "unknown repository"); } } @@ -101,29 +93,23 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { session::reorder_repos(state, &order); changed(session); } - // Not answered to the asker either, though it is the one thing here a - // client could paint locally without waiting. It waits with the rest: - // the accent is the session's, and a client that painted first would be - // the only one showing the new colour for a tick — the same flicker the - // tab switch is written to avoid. + // Waits with the rest rather than painting locally: the accent is the + // session's, and a client that painted first would flicker — the same + // flicker the tab switch is written to avoid. ClientMessage::SetAccent { accent } => { session::set_accent(state, accent); changed(session); } - // Answered to the asker alone, unlike a change to the served set. - // Nothing a reload does shows up in what the other clients are looking - // at — the startup list only reaches repositories opened later, and a - // plugin being replaced is a child process nobody is watching — so - // telling them would be a notice about something they did not do and - // cannot see. + // Answered to the asker alone: nothing a reload does shows up in what + // the other clients are looking at. ClientMessage::ReloadConfig => { let reply = match crate::session::reload::reload_config(state) { Ok(report) => ServerMessage::Reloaded { summary: report.summary(), }, - // The message names the offending key, which is the whole value - // of reporting it rather than saying the file was bad. Err(err) => ServerMessage::Error { + // The message names the offending key — that is the whole + // value of reporting it. message: err.to_string(), }, }; @@ -158,9 +144,8 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { } } -/// Every arm that can have changed the session ends here, so the watcher looks -/// at once instead of on its next tick. Reading the session is still its job — -/// this only wakes it. +/// Wake the watcher so it reads the session at once instead of on its next +/// tick. Reading the session is still the watcher's job — this only wakes it. fn changed(session: &Session) { session.nudge.poke(); } diff --git a/src/daemon/serve.rs b/src/daemon/serve.rs index 3a92c96f..3120f322 100644 --- a/src/daemon/serve.rs +++ b/src/daemon/serve.rs @@ -1,12 +1,9 @@ -//! The daemon's accept loop: one attached client, two threads. A client gets a -//! reader and a writer because the daemon speaks unprompted — the session is -//! shared, so a repository opened in the browser has to reach an attached TUI -//! that never asked. The reader blocks on the socket; the writer drains that -//! client's queue. +//! The daemon's accept loop: one attached client, two threads. The daemon +//! speaks unprompted — the session is shared — so a client gets a reader +//! (blocked on the socket) and a writer (draining that client's queue). //! -//! Sized like the viewer's accept loop and for the same reason — a connection -//! costs threads — but with a much lower ceiling. Clients here are terminals a -//! person is sitting at, not browser tabs. +//! Threaded like the viewer's accept loop but with a lower ceiling: a client +//! here is a person at a terminal, not a browser tab. use anyhow::Context; @@ -32,9 +29,8 @@ pub struct Session { pub(super) state: Arc, pub(super) clients: Arc, /// Each attached client's terminal subscriptions. Kept here rather than on - /// the thread that reads that client's socket, because a repository can - /// appear for reasons that have nothing to do with any client's connection - /// — the browser opened it — and it has to start streaming for everyone. + /// the client's socket thread: a repository can appear without any client + /// asking (the browser opened it) and must start streaming for everyone. /// One lock per client, so following a change for one never delays /// another's keystrokes. pub(super) bridges: Mutex>>>, @@ -48,11 +44,10 @@ pub struct Session { impl Session { /// Bring every attached client's subscriptions in line with `repos`. - /// - /// Oldest client first, because subscribing is what takes a repository's - /// pane sizing (the hub gives it to the newest connection): in ascending id - /// order the newest client subscribes last, so a repository that has just - /// appeared is sized by the same client that sizes all the others. + /// Oldest client first: subscribing takes a repository's pane sizing (the + /// hub gives it to the newest connection), so in ascending id order the + /// newest client subscribes last and sizes a just-appeared repository the + /// same way it sizes all the others. fn follow_all(&self, repos: &[session::SessionRepo]) { let mut bridges: Vec<(u64, Arc>)> = self .bridges @@ -71,12 +66,11 @@ impl Session { } } -/// Serve attached clients until the process ends. -/// -/// Takes a *clone* of the listener rather than the [`DaemonSocket`]: the socket -/// owns the unlink and the instance lock, and this loop blocks in `accept` -/// forever, so a socket parked here would be freed by process exit — which runs -/// no destructor. The caller keeps it and drops it on the way out. +/// Serve attached clients until the process ends. Takes a *clone* of the +/// listener rather than the [`DaemonSocket`]: this loop blocks in `accept` +/// forever and process exit runs no destructor, so a socket parked here would +/// be freed without unlinking it or releasing the lock. The caller keeps the +/// socket and drops it on the way out. /// /// [`DaemonSocket`]: super::socket::DaemonSocket pub fn start( @@ -90,11 +84,10 @@ pub fn start( nudge: Arc::new(super::watch::Nudge::default()), shutdown_tx, }); - // The only thing that sends the served set, so there is one record of what - // clients have been told, one order they are told it in, and a change made - // through the browser reaches them at all. Started here, where it can be - // refused, rather than inside the accept loop: a session without a watcher - // serves clients that never learn what is open. + // The only sender of the served set, so clients are told it in one order + // and changes made through the browser reach them at all. Started outside + // the accept loop: a session without a watcher serves clients that never + // learn what is open. let watched = Arc::clone(&session); std::thread::Builder::new() .name("nightcrow-session-watch".into()) @@ -129,7 +122,7 @@ fn attach(stream: UnixStream, session: &Session) { return; }; // A third handle, so the set can end this connection if the client stops - // draining. Its own two are blocked in `read` and `write`. + // draining — the other two are blocked in `read` and `write`. let Ok(hangup) = stream.try_clone() else { tracing::debug!("daemon: could not split an attaching client's socket"); return; @@ -163,18 +156,16 @@ fn attach(stream: UnixStream, session: &Session) { } }); - // Subscribed before the set can reach this client, so the panes of every - // open repository are already streaming when it learns the repository - // exists. + // Subscribed before the watcher can reach this client, so every open + // repository's panes are already streaming when the client learns the + // repositories exist. bridges.lock().expect("client bridges poisoned").follow( &session::list_session_repos(&session.state), session.state.catalog(), ); - // The set itself is not sent from here. This client is registered as owed - // one (`AttachedClients::connect`) and the watcher answers, which is the - // whole of why a client's frames arrive in the order the session changed — - // see `watch::watch`. Woken rather than waited for: the poke is what stops - // this from sitting behind the tick. + // The set itself is sent by the watcher, to which this client is already + // registered as owed one — that is what keeps a client's frames in the + // order the session changed (see `watch::watch`). session.nudge.poke(); if let Err(err) = super::requests::read_requests(stream, id, session) { diff --git a/src/daemon/socket.rs b/src/daemon/socket.rs index b55e383a..eb9ef01b 100644 --- a/src/daemon/socket.rs +++ b/src/daemon/socket.rs @@ -1,11 +1,9 @@ //! The daemon's Unix socket: where it lives, who may open it, and what to do //! about one left behind by a process that is gone. //! -//! Authentication is the filesystem. The socket sits under the user's own +//! Authentication is the filesystem: the socket sits under the user's own //! `~/.nightcrow` at mode 0600, so reaching it already means being that user — -//! which is the same authority a client would need to run the shells the daemon -//! serves. That is why the attach path carries no password while the browser -//! path does: a TCP port is reachable by anyone who can route to it. +//! which is why the attach path carries no password while the browser path does. use super::lock::InstanceLock; use super::transport::UnixListener; @@ -37,13 +35,10 @@ pub struct DaemonSocket { impl DaemonSocket { /// Bind the socket, refusing to start beside a daemon that already runs. - /// - /// The lock decides, not the socket file. A socket outliving its process is - /// the normal case after a crash or a `kill -9`, and it is indistinguishable - /// from a live one by inspection — connecting to it can even succeed. So the - /// order is: take the lock, and only then treat whatever socket file is - /// there as debris, because holding the lock already proves no other daemon - /// is serving it. + /// The lock decides, not the socket file: a socket outliving its process + /// (crash, `kill -9`) is indistinguishable from a live one by inspection. + /// So: take the lock, and only then treat whatever socket file is there as + /// debris. pub fn bind(path: &Path) -> Result { let lock_path = lock_path_for(path); let Some(lock) = InstanceLock::acquire(&lock_path)? else { @@ -99,11 +94,9 @@ fn restrict_to_owner(path: &Path) -> Result<()> { { // Windows has no mode bits — the posture depends on the directory's // inherited ACL. %USERPROFILE%\.nightcrow's default ACL allows write - // only to owner and admins, so the practical posture holds. - // - // This dependency breaks if the socket path is placed outside the - // user profile. Explicit ACL setting is tracked as a separate task - // (docs/internal plan decision C). + // only to owner and admins, so the practical posture holds. This + // dependency breaks if the socket path is placed outside the user + // profile; explicit ACL setting is tracked separately. let _ = path; } Ok(()) diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index ac090698..505c967a 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -14,8 +14,23 @@ use crate::session::terminal::frame::{ }; use anyhow::Result; use std::collections::{HashMap, VecDeque}; +use std::fmt; use std::sync::{Arc, Mutex}; +/// Terminal bytes one attach connection may have waiting in memory. +/// +/// The daemon's terminal queue can legally replay this much at once (256 +/// one-MiB frames). Keeping the same ceiling here means a valid largest replay +/// can land, while a client that cannot keep up eventually reconnects instead +/// of growing without bound. +pub(crate) const TERMINAL_INBOX_BYTES: usize = 256 * 1024 * 1024; + +/// Work one repository may hand to its emulator in one render tick. +const TERMINAL_DRAIN_MESSAGES: usize = 64; +const TERMINAL_DRAIN_BYTES: usize = 256 * 1024; +/// Messages one attach connection may retain, including control-only traffic. +const TERMINAL_INBOX_MESSAGES: usize = 4096; + /// One thing the daemon said about a repository's terminals. #[derive(Debug)] pub(crate) enum TerminalMessage { @@ -25,52 +40,182 @@ pub(crate) enum TerminalMessage { Output { pane: PaneId, data: Vec }, } +impl TerminalMessage { + fn output_bytes(&self) -> usize { + match self { + Self::Output { data, .. } => data.len(), + Self::Event(_) => 0, + } + } +} + +#[derive(Debug)] +pub(crate) struct TerminalInboxOverflow { + queued: usize, + incoming: usize, + limit: usize, + messages: usize, + message_limit: usize, +} + +impl fmt::Display for TerminalInboxOverflow { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "terminal inbox capacity exceeded: {} queued output bytes plus {} incoming \ + (limit {}), {} messages (limit {})", + self.queued, self.incoming, self.limit, self.messages, self.message_limit + ) + } +} + +impl std::error::Error for TerminalInboxOverflow {} + +#[derive(Debug, Default)] +struct RouterState { + inboxes: HashMap>, + queued_output_bytes: usize, + queued_messages: usize, + overflowed: bool, +} + /// Per-repository inboxes, filled by the connection's reader thread and drained /// by each repository's backend. -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct TerminalRouter { - inboxes: Mutex>>, + state: Mutex, + byte_limit: usize, + message_limit: usize, +} + +impl Default for TerminalRouter { + fn default() -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit: TERMINAL_INBOX_BYTES, + message_limit: TERMINAL_INBOX_MESSAGES, + } + } } impl TerminalRouter { - /// File one message under its repository. - /// - /// The inbox is created on arrival rather than when a backend registers, - /// because the daemon subscribes a client to every open repository the - /// moment it connects: a pane and its scrollback can be on the wire before - /// the client has been told the repository exists. Dropping those would - /// leave panes the client is never told about again — the replay happens - /// once. + /// File one message under its repository. The inbox is created on arrival + /// rather than when a backend registers: the daemon subscribes a client to + /// every open repository the moment it connects, so a pane and its + /// scrollback can be on the wire before the client has been told the + /// repository exists — and the replay happens only once, so dropping those + /// would orphan panes. /// - /// Unbounded for the same reason terminal output is never conflated: - /// dropping bytes corrupts a stream that cannot be re-read. What bounds it - /// is that an inbox nobody drains belongs to a repository this client has - /// not opened a tab for yet, which is the very next thing it does. - pub(crate) fn deliver(&self, repo: &str, message: TerminalMessage) { - self.inboxes - .lock() - .expect("terminal inboxes poisoned") + /// Bytes are never discarded from a live stream. If accepting the whole + /// message would cross the connection-wide ceiling, the router is poisoned + /// and the socket reader ends the connection. A later attach is replayed a + /// coherent stream by the session hub; continuing after a partial drop + /// could never be repaired. + pub(crate) fn deliver( + &self, + repo: &str, + message: TerminalMessage, + ) -> Result<(), TerminalInboxOverflow> { + let incoming = message.output_bytes(); + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let bytes_fit = state + .queued_output_bytes + .checked_add(incoming) + .is_some_and(|total| total <= self.byte_limit); + let messages_fit = state.queued_messages < self.message_limit; + if state.overflowed || !bytes_fit || !messages_fit { + state.overflowed = true; + return Err(TerminalInboxOverflow { + queued: state.queued_output_bytes, + incoming, + limit: self.byte_limit, + messages: state.queued_messages, + message_limit: self.message_limit, + }); + } + state.queued_output_bytes += incoming; + state.queued_messages += 1; + state + .inboxes .entry(repo.to_string()) .or_default() .push_back(message); + Ok(()) } - /// Everything filed for `repo` since the last drain. + /// A bounded FIFO prefix filed for `repo` since the last drain. + /// + /// The byte allowance is soft for the first message: replay frames can be + /// larger than it, and refusing to take the head would wedge the queue. + /// Message count also bounds control-only traffic. Leaving the remainder + /// for the next render tick keeps one loud repository from monopolising the + /// UI while preserving output-before-exit order. pub(crate) fn drain(&self, repo: &str) -> Vec { - let mut inboxes = self.inboxes.lock().expect("terminal inboxes poisoned"); - match inboxes.get_mut(repo) { - Some(inbox) => inbox.drain(..).collect(), - None => Vec::new(), + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let Some(inbox) = state.inboxes.get_mut(repo) else { + return Vec::new(); + }; + let mut drained = Vec::new(); + let mut output_bytes = 0usize; + while drained.len() < TERMINAL_DRAIN_MESSAGES { + let Some(next) = inbox.front() else { break }; + let next_bytes = next.output_bytes(); + if !drained.is_empty() && output_bytes.saturating_add(next_bytes) > TERMINAL_DRAIN_BYTES + { + break; + } + let message = inbox.pop_front().expect("front was present"); + output_bytes += next_bytes; + drained.push(message); } + state.queued_output_bytes -= output_bytes; + state.queued_messages -= drained.len(); + drained } /// Forget the inboxes of repositories that are no longer open, including /// any that were filed for a repository this client never got a tab for. pub(crate) fn retain(&self, open: &[String]) { - self.inboxes - .lock() - .expect("terminal inboxes poisoned") - .retain(|repo, _| open.iter().any(|id| id == repo)); + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let mut removed_bytes = 0usize; + let mut removed_messages = 0usize; + state.inboxes.retain(|repo, inbox| { + let keep = open.iter().any(|id| id == repo); + if !keep { + removed_bytes += inbox + .iter() + .map(TerminalMessage::output_bytes) + .sum::(); + removed_messages += inbox.len(); + } + keep + }); + state.queued_output_bytes -= removed_bytes; + state.queued_messages -= removed_messages; + } + + #[cfg(test)] + pub(super) fn with_byte_limit(byte_limit: usize) -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit, + message_limit: TERMINAL_INBOX_MESSAGES, + } + } + + #[cfg(test)] + fn with_limits(byte_limit: usize, message_limit: usize) -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit, + message_limit, + } + } + + #[cfg(test)] + fn queued_for_test(&self) -> (usize, usize) { + let state = self.state.lock().expect("terminal inboxes poisoned"); + (state.queued_output_bytes, state.queued_messages) } } diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs index 1bec9784..a383f37b 100644 --- a/src/daemon/terminal_link_tests.rs +++ b/src/daemon/terminal_link_tests.rs @@ -27,14 +27,16 @@ fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { // dropping it would lose those panes for good. let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: b"prompt$ ".to_vec(), - }, - ); + router.deliver("r1", created(1)).unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"prompt$ ".to_vec(), + }, + ) + .unwrap(); let inbox = router.drain("r1"); assert_eq!(inbox.len(), 2); @@ -44,8 +46,8 @@ fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { #[test] fn each_repository_drains_only_its_own_traffic() { let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver("r2", created(2)); + router.deliver("r1", created(1)).unwrap(); + router.deliver("r2", created(2)).unwrap(); let first = router.drain("r1"); assert_eq!(first.len(), 1); @@ -58,7 +60,7 @@ fn each_repository_drains_only_its_own_traffic() { #[test] fn a_drained_inbox_is_empty_until_more_arrives() { let router = TerminalRouter::default(); - router.deliver("r1", created(1)); + router.deliver("r1", created(1)).unwrap(); assert_eq!(router.drain("r1").len(), 1); assert!(router.drain("r1").is_empty()); @@ -72,11 +74,243 @@ fn a_drained_inbox_is_empty_until_more_arrives() { fn closing_a_repository_drops_what_was_queued_for_it() { // Its backend went with its tab, so nothing will ever drain this. let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver("gone", created(9)); + router.deliver("r1", created(1)).unwrap(); + router.deliver("gone", created(9)).unwrap(); router.retain(&["r1".to_string()]); assert_eq!(router.drain("r1").len(), 1); assert!(router.drain("gone").is_empty()); } + +#[test] +fn a_drain_takes_a_bounded_fifo_prefix() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("r1", created(pane)).unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), TERMINAL_DRAIN_MESSAGES); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!( + pane_of(first.last().expect("the bounded batch is not empty")), + TERMINAL_DRAIN_MESSAGES as PaneId + ); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), TERMINAL_DRAIN_MESSAGES as PaneId + 1); +} + +#[test] +fn a_busy_repository_does_not_spend_another_repositories_budget() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("busy", created(pane)).unwrap(); + } + router.deliver("quiet", created(999)).unwrap(); + + assert_eq!(router.drain("busy").len(), TERMINAL_DRAIN_MESSAGES); + let quiet = router.drain("quiet"); + assert_eq!(quiet.len(), 1); + assert_eq!(pane_of(&quiet[0]), 999); +} + +#[test] +fn the_byte_budget_leaves_the_next_output_for_the_next_drain() { + let router = TerminalRouter::default(); + let chunk = vec![b'x'; TERMINAL_DRAIN_BYTES / 2]; + for pane in 1..=3 { + router + .deliver( + "r1", + TerminalMessage::Output { + pane, + data: chunk.clone(), + }, + ) + .unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), 2); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!(pane_of(&first[1]), 2); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), 3); +} + +#[test] +fn an_oversized_head_advances_but_exit_waits_behind_its_output() { + let router = TerminalRouter::default(); + let output = vec![b'x'; TERMINAL_DRAIN_BYTES + 1]; + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: output.clone(), + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Event(HubServerMessage::Exited { pane: 1 }), + ) + .unwrap(); + + let first = router.drain("r1"); + assert!(matches!( + first.as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == &output + )); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Event(HubServerMessage::Exited { pane: 1 })] + )); +} + +#[test] +fn crossing_the_byte_ceiling_poison_disconnects_instead_of_making_a_hole() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + let overflow = router.deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"e".to_vec(), + }, + ); + assert!(overflow.is_err()); + assert!( + router.deliver("r1", created(2)).is_err(), + "after one rejected frame, accepting later traffic would create a stream hole" + ); + + let kept = router.drain("r1"); + assert!(matches!( + kept.as_slice(), + [TerminalMessage::Output { data, .. }] if data == b"abcd" + )); +} + +#[test] +fn control_only_traffic_is_bounded_too() { + let router = TerminalRouter::with_limits(usize::MAX, 1); + router.deliver("r1", created(1)).unwrap(); + + assert!( + router.deliver("r1", created(2)).is_err(), + "control events consume memory even though they carry no PTY bytes" + ); +} + +#[test] +fn closing_a_repository_returns_its_share_of_the_byte_allowance() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "gone", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + router.retain(&[]); + + router + .deliver( + "open", + TerminalMessage::Output { + pane: 2, + data: b"wxyz".to_vec(), + }, + ) + .expect("discarding an unopened repository frees its queued bytes"); +} + +#[test] +#[ignore = "release-only terminal inbox load measurement"] +fn measure_one_mb_per_second_terminal_inbox_drain() { + const BYTES_PER_SECOND: usize = 1_000_000; + const FRAMES_PER_SECOND: usize = 60; + const OUTPUT_CHUNK_BYTES: usize = 1024; + const FRAME_BUDGET_NS: u128 = 1_000_000_000 / FRAMES_PER_SECOND as u128; + + let router = TerminalRouter::default(); + let mut produced_bytes = 0usize; + let mut drained_bytes = 0usize; + let mut peak_queued_bytes = 0usize; + let mut peak_queued_messages = 0usize; + let mut drain_calls = 0usize; + let mut total_drain_ns = 0u128; + let mut max_drain_ns = 0u128; + + for frame in 0..FRAMES_PER_SECOND { + let frame_bytes = BYTES_PER_SECOND / FRAMES_PER_SECOND + + usize::from(frame < BYTES_PER_SECOND % FRAMES_PER_SECOND); + let mut remaining = frame_bytes; + while remaining > 0 { + let chunk_bytes = remaining.min(OUTPUT_CHUNK_BYTES); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'x'; chunk_bytes], + }, + ) + .unwrap(); + produced_bytes += chunk_bytes; + remaining -= chunk_bytes; + } + + let (queued_bytes, queued_messages) = router.queued_for_test(); + peak_queued_bytes = peak_queued_bytes.max(queued_bytes); + peak_queued_messages = peak_queued_messages.max(queued_messages); + + let started = std::time::Instant::now(); + let drained = router.drain("r1"); + let elapsed_ns = started.elapsed().as_nanos(); + drain_calls += 1; + total_drain_ns += elapsed_ns; + max_drain_ns = max_drain_ns.max(elapsed_ns); + drained_bytes += drained + .iter() + .map(TerminalMessage::output_bytes) + .sum::(); + } + + let (remaining_bytes, remaining_messages) = router.queued_for_test(); + let max_frame_budget_basis_points = max_drain_ns * 10_000 / FRAME_BUDGET_NS; + println!( + "1 MB/s terminal inbox over {FRAMES_PER_SECOND} simulated frames: \ + peak_queued_bytes={peak_queued_bytes} peak_queued_messages={peak_queued_messages} \ + drain_calls={drain_calls} total_drain_ns={total_drain_ns} \ + max_drain_ns={max_drain_ns} frame_budget_ns={FRAME_BUDGET_NS} \ + max_frame_budget_basis_points={max_frame_budget_basis_points}" + ); + + assert_eq!(produced_bytes, BYTES_PER_SECOND); + assert_eq!(drained_bytes, BYTES_PER_SECOND); + assert_eq!( + peak_queued_bytes, + BYTES_PER_SECOND.div_ceil(FRAMES_PER_SECOND) + ); + assert_eq!(peak_queued_messages, 17); + assert_eq!(drain_calls, FRAMES_PER_SECOND); + assert_eq!((remaining_bytes, remaining_messages), (0, 0)); +} diff --git a/src/daemon/terminals.rs b/src/daemon/terminals.rs index 2d967c67..7494db7e 100644 --- a/src/daemon/terminals.rs +++ b/src/daemon/terminals.rs @@ -1,10 +1,7 @@ //! Wiring one attached client to every open repository's terminals. The hubs -//! are the browser's too — one per repository, already fanning output out to -//! whoever has connected. An attaching client subscribes to all of them at once, -//! because it renders a tab per repository and a pane whose output it stopped -//! reading would fall behind its own scrollback. -//! -//! That costs a thread per client per repository. Bounded by +//! are the browser's too; an attaching client subscribes to all of them at +//! once, because a pane whose output it stopped reading would fall behind its +//! own scrollback. Costs a thread per client per repository, bounded by //! `MAX_ATTACHED_CLIENTS` × `MAX_PROJECTS`. use super::clients::AttachedClients; @@ -33,8 +30,8 @@ pub struct TerminalBridges { open: HashMap, /// Whether this client's first subscription has been made. Attaching is a /// person sitting down, and that is the one moment this client takes the - /// session's sizing. Every subscription after it follows a set that changed - /// — a repository opened in a browser is not an arrival here. + /// session's sizing; every subscription after it follows a set that + /// changed. arrived: bool, } @@ -69,11 +66,9 @@ impl TerminalBridges { }; let arriving = !self.arrived; let Some(bridge) = self.subscribe(&repo.id, arriving, &entry.terminals) else { - // Left out of `open`, and the arrival left unspent, so the next - // set this client is told about tries again. "Next set" is the - // limit: this is called on attach and when the repository set - // changes, so a repository that fails here shows in the client's - // tabs with no terminals until something else moves. + // Left out of `open` and the arrival left unspent, so the next + // set this client is told about retries. A repository that + // fails here shows in the tabs with no terminals until then. continue; }; self.arrived = true; @@ -97,12 +92,9 @@ impl TerminalBridges { hub: &Arc, ) -> Option { let stop = Arc::new(AtomicBool::new(false)); - // The thread first, and the subscription only once it exists. - // Subscribing registers this client with the session's size ownership - // and, on an arrival, takes the sizing off whoever had it. Done in the - // other order, a thread that failed to start left a subscription nobody - // reads — the sizing displaced, this client's one arrival spent, and - // the hub evicting a bridge it can never reach. + // Thread first, subscription only once it exists — the other order + // would leave a failed spawn having displaced the pane sizing and + // spent this client's one arrival on a subscription nobody reads. let (hand_over, take) = std::sync::mpsc::channel::>(); let worker = { let stop = Arc::clone(&stop); @@ -133,15 +125,10 @@ impl TerminalBridges { return None; } }; - // Connecting replays the panes and their scrollback before any live - // frame, so the thread above forwards a usable history first and the - // client's emulators start from the same place the browser's do. - // - // One viewer across every repository it subscribes to: this client is a - // single terminal showing one project at a time. Only the first of - // these subscriptions is an arrival — the rest follow a set that - // changed, and a repository opening elsewhere is not a person sitting - // down here. + // Connecting replays panes and scrollback before any live frame, so the + // client's emulators start where the browser's do. One viewer across + // every repository it subscribes to — this client is a single terminal + // showing one project at a time. let session = Arc::new(hub.connect(ViewerId::Attached(self.client), arriving, None)); let _ = hand_over.send(Arc::clone(&session)); Some(Bridge { @@ -153,12 +140,9 @@ impl TerminalBridges { } /// Turn one hub frame into a frame for this client, tagged with its repository. -/// -/// `hub_client` is this bridge's id at the hub and `attached` is the same -/// client's id on the attach socket. A pane the hub says `hub_client` asked for -/// is relayed as one `attached` asked for, so the client can recognise its own -/// pane by comparing against the id it was given at the handshake — it has no -/// way to know its per-repository hub ids. +/// `hub_client` (this bridge's id at the hub) is rewritten to `attached` (the +/// client's id on the attach socket), so the client can recognise its own panes +/// — it has no way to know its per-repository hub ids. fn tag(repo: &str, frame: TerminalFrame, hub_client: u64, attached: u64) -> Frame { match frame { TerminalFrame::Output { pane, data } => { @@ -182,9 +166,8 @@ fn tag(repo: &str, frame: TerminalFrame, hub_client: u64, attached: u64) -> Fram } } // Parsed and re-encoded rather than passed through as text: the client - // reads one message type, and a control frame smuggled through as an - // opaque string would make the repository tag unreadable without - // parsing it there instead. + // reads one message type, and an opaque string would make the + // repository tag unreadable without parsing it there instead. TerminalFrame::Control(json) => match serde_json::from_str(&json) { Ok(event) => encode_server( &ServerMessage::Terminal { diff --git a/src/daemon/watch.rs b/src/daemon/watch.rs index 7b9f331c..424f2a28 100644 --- a/src/daemon/watch.rs +++ b/src/daemon/watch.rs @@ -1,14 +1,9 @@ -//! Telling attached clients about changes nobody on their connection asked for. -//! The session has two front doors. A repository opened in the browser goes -//! through the HTTP handlers, and nothing on an attach socket is woken by it — -//! so a client that asks for nothing would sit on a tab list that quietly went -//! stale. -//! -//! This is a thread that re-reads the session on a tick and tells everyone when -//! it differs from what they were last told. Observing rather than being +//! Telling attached clients about changes nobody on their connection asked +//! for — a repository opened in the browser wakes nothing on an attach socket. +//! A thread that re-reads the session on a tick and tells everyone when it +//! differs from what they were last told. Observing rather than being //! notified, because a notification is something a mutation added later can -//! forget to send, and the failure then looks like this same bug again. The cost -//! is a comparison of a handful of small structs at a rate nobody can see. +//! forget to send, and the failure then looks like this same bug again. use super::clients::AttachedClients; use super::frame::encode_server; @@ -24,11 +19,9 @@ use std::time::Duration; /// change asked for on an attach socket wakes it immediately (see [`Nudge`]). const TICK: Duration = Duration::from_millis(150); -/// A way to tell the watcher not to wait out its tick. A client that just asked -/// for something is watching for it to happen, so the answer cannot sit behind a -/// poll interval. The change is still *read* from the session rather than passed -/// through here: this only says "look now", so a handler that forgets to poke -/// costs latency, never correctness. +/// A way to tell the watcher not to wait out its tick. The change is still +/// *read* from the session rather than passed through here: a handler that +/// forgets to poke costs latency, never correctness. #[derive(Default)] pub(super) struct Nudge { poked: Mutex, @@ -56,35 +49,26 @@ impl Nudge { } } -/// Watch `state` and tell attached clients the served set: everyone, when it — -/// or which repository the session is focused on, or the accent it is painted in -/// — changes, and whoever is still owed one otherwise. +/// Watch `state` and tell attached clients the served set — everyone, when it +/// (or the focus, or the accent) changes, and whoever is still owed one +/// otherwise. /// -/// **The only place a repository set is sent from.** A client that attaches, or -/// asks for the set outright, is marked as owed one and this is what answers; -/// neither sends its own. That is what makes the order a client sees the order -/// the session changed in — one producer per queue, so there is no pair of -/// frames whose order has to be argued about. Two producers, which is what this -/// replaced, could queue a newer frame ahead of an older one and leave a client -/// on state everyone else had moved off. +/// **The only place a repository set is sent from.** One producer per queue is +/// what makes the order a client sees the order the session changed in; two +/// producers could queue a newer frame ahead of an older one. /// -/// `follow` runs for every client before the set goes out, so a repository that -/// appeared is already streaming its terminals by the time a client is told the -/// tab exists. It runs on an accent change too, where it has nothing to do: it -/// skips repositories already followed, so the alternative — deciding here which -/// kind of change deserves it — would buy a walk over a handful of entries at -/// the price of a branch that can be wrong. The owed-only path does not need it: -/// those clients followed the set when they attached, and it has not changed. +/// `follow` runs for every client before the set goes out, so a repository +/// that appeared is already streaming its terminals by the time a client is +/// told the tab exists. pub(super) fn watch( state: Arc, clients: Arc, nudge: Arc, follow: impl Fn(&[session::SessionRepo]), ) { - // Seeded with the set as it stands, not with nothing: an attaching client is - // owed its own copy and gets one below, so opening with a broadcast would be - // a message that reports no change — and every other client would have to - // treat somebody else's arrival as news. + // Seeded with the set as it stands, not with nothing: an attaching client + // gets its own copy below, and opening with a broadcast would make every + // other client treat somebody else's arrival as news. let mut told = ( summarize(&session::list_session_repos(&state)), session::active_repo(&state), @@ -117,8 +101,8 @@ pub(super) fn watch( clients.broadcast(frame()); told = current; } else { - // Nothing changed, so this says the same thing again to whoever has - // not heard it yet: a client that just attached, or one that asked. + // Nothing changed: say the same thing again to whoever has not + // heard it yet — a client that just attached or asked. for id in clients.take_owed_sets() { clients.send_to(id, frame()); } diff --git a/src/daemon/wire.rs b/src/daemon/wire.rs index 381c2277..401cb6c3 100644 --- a/src/daemon/wire.rs +++ b/src/daemon/wire.rs @@ -15,10 +15,9 @@ use std::io::Write; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex}; -/// The write half of an attach socket. -/// -/// Shared and locked because two kinds of caller send on it. A frame is written -/// under the lock, so two writers cannot interleave halves of one message. +/// The write half of an attach socket. Shared and locked because two kinds of +/// caller send on it; a frame is written under the lock, so two writers cannot +/// interleave halves of one message. pub(super) type Writer = Arc>; /// Write one request. Holds the connection lock for the whole frame. @@ -61,10 +60,9 @@ pub(super) fn pump( } // A read that timed out is not a disconnect. A quiet session is the // normal state, and the handshake's timeout can outlive the - // handshake — macOS refuses to clear the option once the peer has - // gone, so `connect` may hand this loop a socket that still has one. - // Inventing a disconnect out of an idle session is the one failure - // this whole shape exists to avoid. + // handshake (macOS refuses to clear the option once the peer has + // gone). Inventing a disconnect out of an idle session is the one + // failure this whole shape exists to avoid. Err(err) if timed_out(&err) => {} Err(err) => { tracing::warn!(%err, "daemon connection ended"); @@ -102,20 +100,58 @@ pub(super) fn read_routed( pane: output.pane, data: output.data, }, - ); + )?; return Ok(Some(Incoming::Routed)); } let message: ServerMessage = serde_json::from_slice(&frame.payload).context("decoding a message from the daemon")?; - // A terminal event belongs to one repository's panes, so it goes to that - // repository's inbox rather than the general queue — except a refusal, which - // is not about a pane at all but about a request that was turned down, and - // has to reach the tab that shows notices. + // A terminal event belongs to one repository's inbox — except a refusal, + // which is not about a pane and has to reach the tab that shows notices. if let ServerMessage::Terminal { repo, event } = &message && !matches!(event, HubServerMessage::Error { .. }) { - terminals.deliver(repo, TerminalMessage::Event(event.clone())); + terminals.deliver(repo, TerminalMessage::Event(event.clone()))?; return Ok(Some(Incoming::Routed)); } Ok(Some(Incoming::Control(message))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::frame::Frame; + + fn send_output(stream: &mut UnixStream, data: &[u8]) { + let payload = TerminalOutput { + repo: "r1".to_string(), + pane: 1, + data: data.to_vec(), + } + .encode() + .unwrap(); + write_frame(stream, &Frame::terminal(payload)).unwrap(); + stream.flush().unwrap(); + } + + #[test] + fn an_inbox_overflow_ends_routing_instead_of_skipping_a_frame() { + let (mut receiving, mut sending) = UnixStream::pair().unwrap(); + let router = TerminalRouter::with_byte_limit(3); + send_output(&mut sending, b"abc"); + send_output(&mut sending, b"d"); + + assert!(matches!( + read_routed(&mut receiving, &router), + Ok(Some(Incoming::Routed)) + )); + let error = match read_routed(&mut receiving, &router) { + Err(error) => error, + Ok(_) => panic!("the stream must end at the rejected frame"), + }; + assert!(error.to_string().contains("terminal inbox"), "{error:#}"); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Output { data, .. }] if data == b"abc" + )); + } +} diff --git a/src/git/clone.rs b/src/git/clone.rs index 34e59fb3..28167276 100644 --- a/src/git/clone.rs +++ b/src/git/clone.rs @@ -1,9 +1,8 @@ //! Clone a remote repository by delegating to the `git` binary. //! -//! libgit2 is not used: the vendored build carries no SSH transport, knows +//! libgit2 is not used: its vendored build carries no SSH transport, knows //! nothing of credential helpers or `insteadOf` rewrites, and cannot resolve -//! `git@host:path` remotes. Delegating to `git` inherits that whole stack. -//! Nothing here reads stdout — only exit status and stderr-on-failure. +//! `git@host:path` remotes — delegating to `git` inherits that whole stack. mod message; @@ -54,9 +53,8 @@ impl CloneUrlError { /// Accept `url` as a remote address and return the directory name a clone of it /// would create — the same name `git clone ` picks. /// -/// Accepted shapes are the [`ALLOWED_SCHEMES`] and scp-like `user@host:path`. -/// Everything else is rejected, so `ext::`, `file://`, and bare paths never -/// reach `git`. +/// Accepted shapes are the [`ALLOWED_SCHEMES`] and scp-like `user@host:path`; +/// everything else (`ext::`, `file://`, bare paths) is rejected. pub fn validate_clone_url(url: &str) -> Result { let url = url.trim(); if url.is_empty() { @@ -157,8 +155,7 @@ pub fn git_available() -> bool { /// The URL is an argv item behind `--`, never a shell word, so no quoting or /// escaping question arises; [`validate_clone_url`] has already ruled out the /// schemes that would make argv placement insufficient. On failure the error -/// carries the actionable part of git's stderr, which is what tells the user -/// "repository not found" or "permission denied". +/// carries the actionable part of git's stderr. pub fn run_clone(url: &str, dest: &Path) -> anyhow::Result<()> { let mut child = Command::new("git") // Without this a remote that wants credentials makes git open @@ -173,11 +170,11 @@ pub fn run_clone(url: &str, dest: &Path) -> anyhow::Result<()> { "GIT_SSH_COMMAND", "ssh -o ConnectTimeout=30 -o ServerAliveInterval=30 -o ServerAliveCountMax=4", ) - // A rate floor rather than a wall clock: a wall-clock bound cannot - // tell a large repository (legitimately many minutes) from a dead - // connection, while a floor at least scales with what is arriving. - // It is a policy threshold, not a liveness proof — a genuine transfer - // that sits under 1 KiB/s for 60 s is cut too, which is the trade. + // A rate floor rather than a wall clock: a wall-clock bound cannot tell + // a large repository (legitimately many minutes) from a dead connection, + // while a floor scales with what is arriving. It is a policy threshold, + // not a liveness proof — a genuine transfer under 1 KiB/s for 60 s is + // cut too, which is the trade. .arg("-c") .arg("http.lowSpeedLimit=1024") .arg("-c") diff --git a/src/git/clone/message.rs b/src/git/clone/message.rs index 942465c3..06c58dbe 100644 --- a/src/git/clone/message.rs +++ b/src/git/clone/message.rs @@ -1,9 +1,8 @@ //! Turn a failed `git clone`'s stderr into one line for the user. //! -//! Two things stand between that stream and something worth showing. A remote -//! controls it — `remote:` sidebands are printed verbatim — so it cannot be -//! collected unbounded. And git closes a failure with an advice block, so its -//! last line names no cause at all. +//! A remote controls that stream — `remote:` sidebands are printed verbatim — +//! so it cannot be collected unbounded, and git closes a failure with an advice +//! block whose last line names no cause at all. /// Most stderr kept from a failing clone. Only the tail is wanted anyway: the /// reason git gave up is at the end. @@ -48,22 +47,13 @@ pub(super) fn tail_of(mut reader: R) -> String { /// The actionable part of a failed clone's stderr, or `None` if there is none. /// -/// The last line is the wrong pick. An unreachable remote ends like this: -/// -/// ```text -/// ERROR: Repository not found. -/// fatal: Could not read from remote repository. -/// -/// Please make sure you have the correct access rights -/// and the repository exists. -/// ``` -/// -/// so taking the last line shows the tail of a wrapped piece of advice instead -/// of the reason. The reason is the last diagnostic line — and usually the line -/// before it as well, because `fatal: Could not read from remote repository.` is -/// only a wrapper around what the transport actually said ("Repository not -/// found.", "Permission denied (publickey)."). Both are kept and joined; -/// everything after them is dropped. +/// The last line is the wrong pick: an unreachable remote ends with a wrapped +/// piece of advice (`Please make sure you have the correct access rights …`) +/// instead of the reason. The reason is the last diagnostic line — and usually +/// the line before it as well, because `fatal: Could not read from remote +/// repository.` is only a wrapper around what the transport actually said +/// ("Repository not found.", "Permission denied (publickey)."). Both are kept +/// and joined; everything after them is dropped. pub(super) fn actionable(stderr: &str) -> Option { let lines: Vec<&str> = stderr .lines() @@ -71,10 +61,9 @@ pub(super) fn actionable(stderr: &str) -> Option { .filter(|line| !line.is_empty()) .collect(); let Some(last) = lines.iter().rposition(|line| is_diagnostic(line)) else { - // Nothing announced itself as a diagnostic. That is either a transport - // speaking for itself (`ssh: Could not resolve hostname …`) or a git - // whose wording this does not know, and its last line still beats - // saying nothing. + // Nothing announced itself as a diagnostic — a transport speaking for + // itself (`ssh: Could not resolve hostname …`) or a git whose wording + // this does not know. Its last line still beats saying nothing. return lines.last().map(|line| (*line).to_string()); }; let mut kept = Vec::with_capacity(2); diff --git a/src/git/diff.rs b/src/git/diff.rs index 8d47c806..ca2005ff 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -2,6 +2,7 @@ mod commit_log; mod conflict; mod diff_load; mod file_load; +mod load_worker; mod refs; mod snapshot; mod types; @@ -16,6 +17,9 @@ pub use diff_load::{ parse_hunk_new_start, }; pub use file_load::{load_commit_file, load_commit_file_blob, load_workdir_file}; +pub(crate) use load_worker::{ + GitLoadOperation, GitLoadPayload, GitLoadReply, GitLoadRequest, GitLoadWorker, LoadLane, +}; pub use refs::{LogDecorations, RefKind, RefLabel, load_log_decorations}; pub use snapshot::load_snapshot; #[cfg(test)] diff --git a/src/git/diff/commit_log.rs b/src/git/diff/commit_log.rs index a2b0550e..5d036994 100644 --- a/src/git/diff/commit_log.rs +++ b/src/git/diff/commit_log.rs @@ -73,10 +73,9 @@ pub fn load_commit_log_from( /// Render a commit oid as the conventional 7-character abbreviated form. /// -/// Previously used `repo.find_object(...).short_id()`, which computes the -/// minimum unique prefix at O(log n) ODB lookups per commit. git's own default -/// `core.abbrev` is 7, so a fixed 7-char prefix matches the familiar form -/// while making this O(1). +/// A fixed 7-char prefix matches git's own default `core.abbrev`; the previous +/// `repo.find_object(...).short_id()` computed the minimum unique prefix at +/// O(log n) ODB lookups per commit, while this is O(1). pub(crate) fn short_oid(oid: Oid) -> String { let s = oid.to_string(); s.get(..7).unwrap_or(&s).to_string() @@ -103,9 +102,9 @@ pub fn head_commit_oid(repo: &Repository) -> Result> { pub(crate) fn is_empty_head(err: &git2::Error) -> bool { // libgit2 reports "reference 'refs/heads/' not found" for empty - // repos with a class of Reference but a generic error code, so we keep - // the message fallback. libgit2 does not localize internal messages, so - // the match is portable. + // repos with a class of Reference but a generic error code, so the message + // fallback stays; libgit2 does not localize internal messages, so the + // match is portable. let missing_head_reference = err.class() == git2::ErrorClass::Reference && err.message().contains("not found"); diff --git a/src/git/diff/conflict.rs b/src/git/diff/conflict.rs index 40ceee72..fc33595b 100644 --- a/src/git/diff/conflict.rs +++ b/src/git/diff/conflict.rs @@ -1,17 +1,14 @@ -//! Saying what a conflict is when there is nothing to diff. +//! Names for conflicts that have nothing to diff against HEAD. //! -//! A conflicted path with markers in it diffs against HEAD like any other -//! change. The rest do not: git leaves our version on disk for a modify/delete, -//! keeps ours for a binary clash, and a rename/rename leaves a file that never -//! differed from HEAD at all. Those answer with no hunks, which on screen is -//! indistinguishable from a file nobody touched — for a row the status list is -//! showing as unmerged. +//! Most unmerged shapes leave no hunks — on screen that reads as a file nobody +//! touched, for a row the status list shows as unmerged — so each gets a +//! synthetic hunk saying what the conflict is. use crate::git::diff::types::{DiffHunk, DiffLine, LineKind}; use git2::Repository; -/// How `path` is conflicted, in git's own words for the same shapes -/// (`git status` calls them the same thing), or `None` if it is not. +/// How `path` is conflicted, worded the way `git status` words the same +/// shapes, or `None` if it is not conflicted. fn describe(repo: &Repository, path: &str) -> Option<&'static str> { let index = repo.index().ok()?; let wanted = path.as_bytes(); @@ -45,10 +42,9 @@ fn describe(repo: &Repository, path: &str) -> Option<&'static str> { ) } -/// One synthetic hunk naming the conflict, shaped like the one a binary change -/// gets: a header and a single line belonging to neither side, so a reader — -/// and the viewer's "is this text?" check — treats it as something to read -/// rather than something to edit against line numbers. +/// One synthetic hunk naming the conflict, shaped like a binary change's: a +/// header plus a line belonging to neither side, so a reader — and the +/// viewer's "is this text?" check — reads it as text, not line-numbered edits. pub(super) fn summary_hunk(repo: &Repository, path: &str) -> Option { let description = describe(repo, path)?; Some(DiffHunk { diff --git a/src/git/diff/diff_load.rs b/src/git/diff/diff_load.rs index 11b6a66c..2919219c 100644 --- a/src/git/diff/diff_load.rs +++ b/src/git/diff/diff_load.rs @@ -149,10 +149,9 @@ fn diff_options(pathspec: Option<&str>) -> DiffOptions { /// The two sides differ only in a rename git paired, which it does when the /// pathspec reached both halves — otherwise each half arrives as its own /// `Deleted` or `Added` delta holding that half's path on both sides. Prefix -/// matching is what reaches both, so this is not only the directory case: a -/// file replaced by a directory of the same name (`foo` becoming `foo/x`) -/// pairs under the pathspec `foo`, and there the old side is the only side -/// that equals what was asked for. +/// matching is what reaches both: a file replaced by a directory of the same +/// name (`foo` becoming `foo/x`) pairs under the pathspec `foo`, and there the +/// old side is the only side that equals what was asked for. fn delta_is_about(delta: &DiffDelta<'_>, wanted: &str) -> bool { [delta.new_file().path(), delta.old_file().path()] .into_iter() @@ -178,9 +177,9 @@ fn collect_hunks( ) -> Result> { let hunks: RefCell> = RefCell::new(Vec::new()); // Every callback is handed the delta it belongs to, so each one answers the - // `only` question for itself. Deciding once in `file_cb` and remembering it + // `only` question for itself; deciding once in `file_cb` and remembering it // would make the result depend on a callback order libgit2 is free to - // change, for nothing. + // change. let wanted = |delta: &DiffDelta<'_>| only.is_none_or(|only| delta_is_about(delta, only)); diff.foreach( diff --git a/src/git/diff/file_load.rs b/src/git/diff/file_load.rs index a4c2b685..a6f1bcea 100644 --- a/src/git/diff/file_load.rs +++ b/src/git/diff/file_load.rs @@ -1,8 +1,7 @@ //! Reading a file's whole contents — from the working tree, or from a commit. //! -//! Separate from `diff_load.rs`, which is about what changed. These answer the -//! other question a person asks of the same path: not "what moved" but "what -//! does it say", which is what the viewer switches to from a diff. +//! Separate from `diff_load.rs`, which is about what changed; these answer the +//! other question a person asks of the same path: "what does it say". use super::types::StatusKind; use anyhow::{Context, Result}; @@ -76,13 +75,12 @@ pub fn load_commit_file_blob( /// The file's contents as of `oid`. /// -/// Which side to read is decided here rather than taken from the caller. A path +/// Which side to read is decided here rather than taken from the caller: a path /// deleted in a commit is not in that commit's own tree — its content is in the /// parent's — and the repository already knows which case this is. -/// [`load_commit_file_blob`] is told instead, because the TUI has the status +/// [`load_commit_file_blob`] is told instead because the TUI has the status /// beside the row it is acting on; a request arriving over the wire has no such -/// thing to be trusted with, and asking for it would add an input to validate -/// for an answer that can simply be looked up. +/// thing to be trusted with. pub fn load_commit_file(repo: &Repository, oid: Oid, file_path: &str) -> Result { let commit = repo.find_commit(oid).context("failed to find commit")?; let path = std::path::Path::new(file_path); @@ -116,10 +114,9 @@ pub fn load_commit_file(repo: &Repository, oid: Oid, file_path: &str) -> Result< /// A blob as text, refusing one too large to show *before* it is loaded. /// /// The size comes from the object database's header rather than from the blob, -/// because reading the blob is what there is to avoid: a repository can hold an -/// object larger than this process should hold in memory, and finding that out -/// from `Blob::content()` means having already paid for it. The working-tree -/// path guards the same way, off the file's metadata. +/// because reading the blob is what there is to avoid: finding a too-large +/// object from `Blob::content()` means having already paid for it. The +/// working-tree path guards the same way, off the file's metadata. fn read_blob(repo: &Repository, oid: Oid) -> Result { let odb = repo.odb().context("failed to open the object database")?; let (size, _) = odb diff --git a/src/git/diff/load_worker.rs b/src/git/diff/load_worker.rs new file mode 100644 index 00000000..ba4b15e5 --- /dev/null +++ b/src/git/diff/load_worker.rs @@ -0,0 +1,195 @@ +//! Conflated background loads for the TUI git views. + +mod execute; +mod lifecycle; +mod retry; +mod runtime; + +use std::sync::{Arc, Condvar, Mutex, mpsc}; + +use git2::Oid; + +use super::{ChangedFile, DiffHunk, LogDecorations, StatusKind}; +use runtime::WorkerThread; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LoadLane { + Diff, + File, + CommitFiles, + Decorations, +} + +impl LoadLane { + const COUNT: usize = 4; + + fn index(self) -> usize { + match self { + Self::Diff => 0, + Self::File => 1, + Self::CommitFiles => 2, + Self::Decorations => 3, + } + } +} + +#[derive(Clone)] +pub(crate) enum GitLoadOperation { + StatusDiff(String), + CommitDiff(Oid), + CommitFileDiff { + oid: Oid, + path: String, + }, + WorkdirFile(String), + CommitFile { + oid: Oid, + path: String, + status: StatusKind, + }, + CommitFiles(Oid), + Decorations, +} + +impl GitLoadOperation { + pub(crate) fn lane(&self) -> LoadLane { + match self { + Self::StatusDiff(_) | Self::CommitDiff(_) | Self::CommitFileDiff { .. } => { + LoadLane::Diff + } + Self::WorkdirFile(_) | Self::CommitFile { .. } => LoadLane::File, + Self::CommitFiles(_) => LoadLane::CommitFiles, + Self::Decorations => LoadLane::Decorations, + } + } +} + +#[derive(Clone)] +pub(crate) struct GitLoadRequest { + pub(crate) repo: String, + pub(crate) generation: u64, + pub(crate) operation: GitLoadOperation, +} + +pub(crate) enum GitLoadPayload { + Diff(Vec), + File(String), + CommitFiles(Vec), + Decorations(LogDecorations), +} + +pub(crate) struct GitLoadReply { + pub(crate) request: GitLoadRequest, + pub(crate) result: Result, +} + +struct Pending { + requests: [Option; LoadLane::COUNT], + latest: [u64; LoadLane::COUNT], + next_lane: usize, + stopped: bool, +} + +impl Default for Pending { + fn default() -> Self { + Self { + requests: std::array::from_fn(|_| None), + latest: [0; LoadLane::COUNT], + next_lane: 0, + stopped: false, + } + } +} + +impl Pending { + fn replace(&mut self, request: GitLoadRequest) { + let index = request.operation.lane().index(); + self.latest[index] = request.generation; + self.requests[index] = Some(request); + } + + fn take_next(&mut self) -> Option { + for offset in 0..LoadLane::COUNT { + let index = (self.next_lane + offset) % LoadLane::COUNT; + if let Some(request) = self.requests[index].take() { + self.next_lane = (index + 1) % LoadLane::COUNT; + return Some(request); + } + } + None + } + + fn cancel(&mut self, lane: LoadLane, generation: u64) { + let index = lane.index(); + self.latest[index] = generation; + self.requests[index] = None; + } + + fn is_latest(&self, request: &GitLoadRequest) -> bool { + self.latest[request.operation.lane().index()] == request.generation + } +} + +pub(crate) struct GitLoadWorker { + shared: Arc<(Mutex, Condvar)>, + replies: mpsc::Receiver, + worker: Mutex, +} + +impl GitLoadWorker { + pub(crate) fn spawn() -> Self { + Self::new(WorkerThread::new) + } + + fn new(make_worker: impl FnOnce(mpsc::Sender) -> WorkerThread) -> Self { + let shared = Arc::new((Mutex::new(Pending::default()), Condvar::new())); + let (reply_tx, replies) = mpsc::channel(); + let worker = Self { + shared, + replies, + worker: Mutex::new(make_worker(reply_tx)), + }; + worker.ensure_started(); + worker + } + + pub(crate) fn submit(&self, request: GitLoadRequest) { + let (lock, wake) = &*self.shared; + let mut pending = lock.lock().unwrap_or_else(|e| e.into_inner()); + if pending.stopped { + return; + } + pending.replace(request); + wake.notify_one(); + drop(pending); + self.ensure_started(); + } + + pub(crate) fn cancel(&self, lane: LoadLane, generation: u64) { + let mut pending = self.shared.0.lock().unwrap_or_else(|e| e.into_inner()); + pending.cancel(lane, generation); + } + + pub(crate) fn try_recv(&self) -> Result { + self.ensure_started(); + self.replies.try_recv() + } + + fn ensure_started(&self) { + let mut worker = self.worker.lock().unwrap_or_else(|e| e.into_inner()); + worker.ensure_started(Arc::clone(&self.shared)); + } +} + +impl Drop for GitLoadWorker { + fn drop(&mut self) { + let (lock, wake) = &*self.shared; + lock.lock().unwrap_or_else(|e| e.into_inner()).stopped = true; + wake.notify_one(); + let worker = self.worker.get_mut().unwrap_or_else(|e| e.into_inner()); + worker.finish(); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/git/diff/load_worker/execute.rs b/src/git/diff/load_worker/execute.rs new file mode 100644 index 00000000..44cd7dd5 --- /dev/null +++ b/src/git/diff/load_worker/execute.rs @@ -0,0 +1,56 @@ +use git2::Repository; + +use super::super::{ + load_commit_diff, load_commit_file_blob, load_commit_file_diff, load_commit_files, + load_file_diff, load_log_decorations, load_workdir_file, +}; +use super::{GitLoadOperation, GitLoadPayload, GitLoadRequest}; + +pub(super) fn execute( + request: &GitLoadRequest, + cached: &mut Option<(String, Repository)>, +) -> anyhow::Result { + if cached + .as_ref() + .is_none_or(|(path, _)| path != &request.repo) + { + let repo = Repository::discover(&request.repo) + .map_err(|e| anyhow::anyhow!(crate::git::format_discover_error(&e)))?; + *cached = Some((request.repo.clone(), repo)); + } + let repo = &cached.as_ref().expect("repository was opened").1; + let result = match &request.operation { + GitLoadOperation::StatusDiff(path) => load_file_diff(repo, path).map(GitLoadPayload::Diff), + GitLoadOperation::CommitDiff(oid) => load_commit_diff(repo, *oid).map(GitLoadPayload::Diff), + GitLoadOperation::CommitFileDiff { oid, path } => { + load_commit_file_diff(repo, *oid, path).map(GitLoadPayload::Diff) + } + GitLoadOperation::WorkdirFile(path) => { + load_workdir_file(repo, path).map(GitLoadPayload::File) + } + GitLoadOperation::CommitFile { oid, path, status } => { + load_commit_file_blob(repo, *oid, path, *status).map(GitLoadPayload::File) + } + GitLoadOperation::CommitFiles(oid) => { + load_commit_files(repo, *oid).map(GitLoadPayload::CommitFiles) + } + GitLoadOperation::Decorations => { + load_log_decorations(repo).map(GitLoadPayload::Decorations) + } + }; + if result.as_ref().err().is_some_and(is_repository_error) { + *cached = None; + } + result +} + +fn is_repository_error(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|git_error| { + matches!( + git_error.class(), + git2::ErrorClass::Os | git2::ErrorClass::Repository + ) + }) +} diff --git a/src/git/diff/load_worker/lifecycle.rs b/src/git/diff/load_worker/lifecycle.rs new file mode 100644 index 00000000..782c6dcb --- /dev/null +++ b/src/git/diff/load_worker/lifecycle.rs @@ -0,0 +1,200 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub(super) const MAX_IN_FLIGHT: usize = 8; +const MAX_IN_FLIGHT_PER_REPO: usize = 1; +pub(super) const MAX_WORKER_THREADS: usize = crate::workspace::MAX_PROJECTS + MAX_IN_FLIGHT; +pub(super) const JOIN_GRACE: Duration = Duration::from_millis(5); + +#[derive(Default)] +struct WorkerSlotState { + active: usize, + peak: usize, +} + +struct WorkerSlots { + state: Mutex, +} + +impl WorkerSlots { + fn new() -> Self { + Self { + state: Mutex::new(WorkerSlotState::default()), + } + } + + fn try_acquire(&self) -> Option> { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state.active >= MAX_WORKER_THREADS { + return None; + } + state.active += 1; + state.peak = state.peak.max(state.active); + Some(WorkerPermit { slots: self }) + } +} + +fn worker_slots() -> &'static WorkerSlots { + static SLOTS: OnceLock = OnceLock::new(); + SLOTS.get_or_init(WorkerSlots::new) +} + +pub(super) struct WorkerPermit<'a> { + slots: &'a WorkerSlots, +} + +impl WorkerPermit<'static> { + pub(super) fn try_acquire() -> Option { + worker_slots().try_acquire() + } +} + +impl Drop for WorkerPermit<'_> { + fn drop(&mut self) { + let mut state = self.slots.state.lock().unwrap_or_else(|e| e.into_inner()); + state.active = state.active.saturating_sub(1); + } +} + +pub(super) fn finish_or_detach(handle: JoinHandle<()>) { + let deadline = Instant::now() + JOIN_GRACE; + while !handle.is_finished() && Instant::now() < deadline { + thread::yield_now(); + } + if handle.is_finished() { + join_finished(handle); + } +} + +pub(super) fn join_finished(handle: JoinHandle<()>) { + debug_assert!(handle.is_finished()); + if let Err(error) = handle.join() { + tracing::warn!(?error, "git load worker panicked"); + } +} + +struct Waiter { + ticket: u64, + repo: String, +} + +#[derive(Default)] +struct AdmissionState { + total: usize, + repos: HashMap, + waiting: VecDeque, + next_ticket: u64, + next_admission: u64, + peak_total: usize, + peak_repo: usize, +} + +struct AdmissionLimiter { + state: Mutex, + wake: Condvar, +} + +impl AdmissionLimiter { + fn new() -> Self { + Self { + state: Mutex::new(AdmissionState::default()), + wake: Condvar::new(), + } + } + + fn acquire(&self, repo: &str, stopped: impl Fn() -> bool) -> Option> { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let ticket = state.next_ticket; + state.next_ticket = state.next_ticket.wrapping_add(1); + state.waiting.push_back(Waiter { + ticket, + repo: repo.to_string(), + }); + + loop { + if stopped() { + remove_waiter(&mut state.waiting, ticket); + self.wake.notify_all(); + return None; + } + let eligible = state.total < MAX_IN_FLIGHT + && state + .waiting + .iter() + .find(|waiter| { + state.repos.get(&waiter.repo).copied().unwrap_or(0) < MAX_IN_FLIGHT_PER_REPO + }) + .is_some_and(|waiter| waiter.ticket == ticket); + if eligible { + remove_waiter(&mut state.waiting, ticket); + state.total += 1; + let repo_count = state.repos.entry(repo.to_string()).or_default(); + *repo_count += 1; + let repo_count = *repo_count; + state.next_admission = state.next_admission.wrapping_add(1); + let admission = state.next_admission; + state.peak_total = state.peak_total.max(state.total); + state.peak_repo = state.peak_repo.max(repo_count); + return Some(InFlightPermit { + limiter: self, + repo: repo.to_string(), + _admission: admission, + }); + } + state = self + .wake + .wait_timeout(state, JOIN_GRACE) + .unwrap_or_else(|e| e.into_inner()) + .0; + } + } +} + +fn remove_waiter(waiting: &mut VecDeque, ticket: u64) { + if let Some(index) = waiting.iter().position(|waiter| waiter.ticket == ticket) { + waiting.remove(index); + } +} + +fn in_flight() -> &'static AdmissionLimiter { + static LIMITER: OnceLock = OnceLock::new(); + LIMITER.get_or_init(AdmissionLimiter::new) +} + +pub(super) struct InFlightPermit<'a> { + limiter: &'a AdmissionLimiter, + repo: String, + _admission: u64, +} + +impl InFlightPermit<'static> { + pub(super) fn acquire(repo: &str, stopped: impl Fn() -> bool) -> Option { + in_flight().acquire(repo, stopped) + } +} + +impl Drop for InFlightPermit<'_> { + fn drop(&mut self) { + let mut state = self.limiter.state.lock().unwrap_or_else(|e| e.into_inner()); + state.total = state.total.saturating_sub(1); + if let Some(count) = state.repos.get_mut(&self.repo) { + *count = count.saturating_sub(1); + if *count == 0 { + state.repos.remove(&self.repo); + } + } + self.limiter.wake.notify_all(); + } +} + +#[cfg(test)] +impl InFlightPermit<'_> { + fn admission_for_test(&self) -> u64 { + self._admission + } +} + +#[cfg(test)] +mod tests; diff --git a/src/git/diff/load_worker/lifecycle/tests.rs b/src/git/diff/load_worker/lifecycle/tests.rs new file mode 100644 index 00000000..0d7d96a0 --- /dev/null +++ b/src/git/diff/load_worker/lifecycle/tests.rs @@ -0,0 +1,152 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Barrier, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use super::{AdmissionLimiter, MAX_IN_FLIGHT, MAX_WORKER_THREADS, WorkerSlots, finish_or_detach}; + +#[test] +fn retiring_a_slow_worker_never_waits_for_its_completion() { + let slots = Arc::new(WorkerSlots::new()); + let release = Arc::new(AtomicBool::new(false)); + let release_later = Arc::clone(&release); + let releaser = thread::spawn(move || { + thread::sleep(Duration::from_millis(300)); + release_later.store(true, Ordering::SeqCst); + }); + + let mut capped_retire_elapsed = Duration::ZERO; + for index in 0..MAX_WORKER_THREADS { + let slots = Arc::clone(&slots); + let release = Arc::clone(&release); + let (ready_tx, ready_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let _permit = slots.try_acquire().expect("worker slot available"); + ready_tx.send(()).unwrap(); + while !release.load(Ordering::SeqCst) { + thread::yield_now(); + } + }); + ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let started = Instant::now(); + finish_or_detach(handle); + if index == 16 { + capped_retire_elapsed = started.elapsed(); + } + } + + assert!(capped_retire_elapsed < Duration::from_millis(50)); + assert!(slots.try_acquire().is_none()); + assert_eq!(slots.state.lock().unwrap().peak, MAX_WORKER_THREADS); + releaser.join().unwrap(); + wait_until_worker_slots_are_free(&slots); +} + +#[test] +fn ninth_repo_enters_before_eight_repos_can_refill() { + let limiter = Arc::new(AdmissionLimiter::new()); + let start = Arc::new(Barrier::new(MAX_IN_FLIGHT + 1)); + let release = Arc::new(Barrier::new(MAX_IN_FLIGHT + 1)); + let (initial_tx, initial_rx) = mpsc::channel(); + let (refill_tx, refill_rx) = mpsc::channel(); + let mut handles = Vec::new(); + + for index in 0..MAX_IN_FLIGHT { + let limiter = Arc::clone(&limiter); + let start = Arc::clone(&start); + let release = Arc::clone(&release); + let initial_tx = initial_tx.clone(); + let refill_tx = refill_tx.clone(); + handles.push(thread::spawn(move || { + let repo = format!("repo-{index}"); + start.wait(); + let permit = limiter.acquire(&repo, || false).unwrap(); + initial_tx.send(permit.admission_for_test()).unwrap(); + release.wait(); + drop(permit); + let refill = limiter.acquire(&repo, || false).unwrap(); + refill_tx.send(refill.admission_for_test()).unwrap(); + })); + } + start.wait(); + let mut initial: Vec<_> = (0..MAX_IN_FLIGHT) + .map(|_| initial_rx.recv_timeout(Duration::from_secs(1)).unwrap()) + .collect(); + initial.sort_unstable(); + assert_eq!(initial, (1..=MAX_IN_FLIGHT as u64).collect::>()); + + let ninth_limiter = Arc::clone(&limiter); + let (ninth_tx, ninth_rx) = mpsc::channel(); + let ninth = thread::spawn(move || { + let permit = ninth_limiter.acquire("repo-8", || false).unwrap(); + ninth_tx.send(permit.admission_for_test()).unwrap(); + }); + wait_until_queued(&limiter, "repo-8"); + release.wait(); + + assert_eq!(ninth_rx.recv_timeout(Duration::from_secs(1)).unwrap(), 9); + let refills: Vec<_> = (0..MAX_IN_FLIGHT) + .map(|_| refill_rx.recv_timeout(Duration::from_secs(1)).unwrap()) + .collect(); + assert!(refills.into_iter().all(|admission| admission > 9)); + ninth.join().unwrap(); + for handle in handles { + handle.join().unwrap(); + } +} + +#[test] +fn cancelled_ticket_leaves_the_admission_queue() { + let limiter = Arc::new(AdmissionLimiter::new()); + let mut holders = Vec::new(); + for index in 0..MAX_IN_FLIGHT { + holders.push( + limiter + .acquire(&format!("holder-{index}"), || false) + .unwrap(), + ); + } + let stopped = Arc::new(AtomicBool::new(false)); + let waiter_limiter = Arc::clone(&limiter); + let waiter_stopped = Arc::clone(&stopped); + let waiter = thread::spawn(move || { + waiter_limiter + .acquire("cancelled", || waiter_stopped.load(Ordering::SeqCst)) + .is_none() + }); + wait_until_queued(&limiter, "cancelled"); + stopped.store(true, Ordering::SeqCst); + + assert!(waiter.join().unwrap()); + assert!(limiter.state.lock().unwrap().waiting.is_empty()); + drop(holders); +} + +fn wait_until_queued(limiter: &AdmissionLimiter, repo: &str) { + let deadline = Instant::now() + Duration::from_secs(1); + while Instant::now() < deadline { + if limiter + .state + .lock() + .unwrap() + .waiting + .iter() + .any(|waiter| waiter.repo == repo) + { + return; + } + thread::yield_now(); + } + panic!("{repo} was not queued before the deadline"); +} + +fn wait_until_worker_slots_are_free(slots: &WorkerSlots) { + let deadline = Instant::now() + Duration::from_secs(1); + while Instant::now() < deadline { + if slots.state.lock().unwrap().active == 0 { + return; + } + thread::yield_now(); + } + panic!("detached workers did not release their slots"); +} diff --git a/src/git/diff/load_worker/retry.rs b/src/git/diff/load_worker/retry.rs new file mode 100644 index 00000000..9f2d98dc --- /dev/null +++ b/src/git/diff/load_worker/retry.rs @@ -0,0 +1,42 @@ +use std::time::{Duration, Instant}; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(16); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(1); +const WARNING_INTERVAL: Duration = Duration::from_secs(30); + +pub(super) struct SpawnRetry { + next_retry: Option, + retry_delay: Duration, + next_warning: Option, +} + +impl Default for SpawnRetry { + fn default() -> Self { + Self { + next_retry: None, + retry_delay: INITIAL_RETRY_DELAY, + next_warning: None, + } + } +} + +impl SpawnRetry { + pub(super) fn is_ready(&self, now: Instant) -> bool { + self.next_retry.is_none_or(|deadline| now >= deadline) + } + + pub(super) fn record_failure(&mut self, now: Instant) -> bool { + self.next_retry = Some(now + self.retry_delay); + self.retry_delay = self.retry_delay.saturating_mul(2).min(MAX_RETRY_DELAY); + + if self.next_warning.is_some_and(|deadline| now < deadline) { + return false; + } + self.next_warning = Some(now + WARNING_INTERVAL); + true + } + + pub(super) fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/src/git/diff/load_worker/runtime.rs b/src/git/diff/load_worker/runtime.rs new file mode 100644 index 00000000..682df2e7 --- /dev/null +++ b/src/git/diff/load_worker/runtime.rs @@ -0,0 +1,225 @@ +use std::io; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, Condvar, Mutex, mpsc}; +use std::thread::{self, JoinHandle}; +use std::time::Instant; + +use git2::Repository; + +use super::execute::execute; +use super::lifecycle::{InFlightPermit, WorkerPermit, finish_or_detach, join_finished}; +use super::retry::SpawnRetry; +use super::{GitLoadPayload, GitLoadReply, GitLoadRequest, Pending}; + +const TASK_PANIC_ERROR: &str = "background git load panicked"; + +type Shared = Arc<(Mutex, Condvar)>; + +pub(super) struct WorkerThread { + reply_tx: mpsc::Sender, + handle: Option>, + retry: SpawnRetry, + #[cfg(test)] + hooks: Option>, +} + +impl WorkerThread { + pub(super) fn new(reply_tx: mpsc::Sender) -> Self { + Self { + reply_tx, + handle: None, + retry: SpawnRetry::default(), + #[cfg(test)] + hooks: None, + } + } + + pub(super) fn ensure_started(&mut self, shared: Shared) { + if self.handle.as_ref().is_some_and(JoinHandle::is_finished) { + join_finished(self.handle.take().expect("finished worker handle exists")); + } + if self.handle.is_some() { + return; + } + let now = self.now(); + if !self.retry.is_ready(now) { + return; + } + let Some(permit) = WorkerPermit::try_acquire() else { + return; + }; + let task = WorkerTask { + shared, + replies: self.reply_tx.clone(), + _worker_permit: permit, + #[cfg(test)] + executor: self.hooks.as_ref().map(|hooks| Arc::clone(&hooks.executor)), + }; + match self.spawn_task(task) { + Ok(handle) => { + self.handle = Some(handle); + self.retry.reset(); + } + Err(error) => { + if self.retry.record_failure(now) { + self.warn_spawn_failure(&error); + } + } + } + } + + pub(super) fn finish(&mut self) { + if let Some(handle) = self.handle.take() { + finish_or_detach(handle); + } + } + + fn spawn_task(&self, task: WorkerTask) -> io::Result> { + #[cfg(test)] + if let Some(hooks) = &self.hooks { + return (hooks.spawner)(task); + } + spawn_task(task) + } + + fn now(&self) -> Instant { + #[cfg(test)] + if let Some(hooks) = &self.hooks { + return (hooks.now)(); + } + Instant::now() + } + + fn warn_spawn_failure(&self, error: &io::Error) { + #[cfg(test)] + if let Some(hooks) = &self.hooks { + (hooks.on_warning)(error); + return; + } + tracing::warn!(?error, "failed to spawn git load worker"); + } + + #[cfg(test)] + pub(super) fn with_hooks(reply_tx: mpsc::Sender, hooks: Arc) -> Self { + Self { + reply_tx, + handle: None, + retry: SpawnRetry::default(), + hooks: Some(hooks), + } + } +} + +pub(super) struct WorkerTask { + shared: Shared, + replies: mpsc::Sender, + _worker_permit: WorkerPermit<'static>, + #[cfg(test)] + executor: Option>, +} + +pub(super) fn spawn_task(task: WorkerTask) -> io::Result> { + thread::Builder::new() + .name("git-load-worker".into()) + .spawn(move || task.run()) +} + +impl WorkerTask { + fn run(self) { + worker_loop( + self.shared, + self.replies, + self._worker_permit, + #[cfg(test)] + self.executor, + ); + } +} + +fn worker_loop( + shared: Shared, + replies: mpsc::Sender, + _worker_permit: WorkerPermit<'static>, + #[cfg(test)] executor: Option>, +) { + let mut cached: Option<(String, Repository)> = None; + loop { + let request = { + let (lock, wake) = &*shared; + let mut pending = lock.lock().unwrap_or_else(|e| e.into_inner()); + while !pending.stopped && pending.requests.iter().all(Option::is_none) { + pending = wake.wait(pending).unwrap_or_else(|e| e.into_inner()); + } + if pending.stopped { + return; + } + pending.take_next().expect("a pending request was observed") + }; + + if !shared + .0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_latest(&request) + { + continue; + } + let Some(_permit) = InFlightPermit::acquire(&request.repo, || { + shared.0.lock().unwrap_or_else(|e| e.into_inner()).stopped + }) else { + return; + }; + let result = execute_safely( + &request, + &mut cached, + #[cfg(test)] + executor.as_deref(), + ); + if replies.send(GitLoadReply { request, result }).is_err() { + return; + } + } +} + +fn execute_safely( + request: &GitLoadRequest, + cached: &mut Option<(String, Repository)>, + #[cfg(test)] executor: Option<&TestExecutor>, +) -> Result { + let result = catch_unwind(AssertUnwindSafe(|| { + #[cfg(test)] + if let Some(executor) = executor { + return executor(request, cached); + } + execute(request, cached) + })); + match result { + Ok(result) => result.map_err(|error| error.to_string()), + Err(_) => { + *cached = None; + Err(TASK_PANIC_ERROR.into()) + } + } +} + +#[cfg(test)] +pub(super) type TestExecutor = dyn Fn(&GitLoadRequest, &mut Option<(String, Repository)>) -> anyhow::Result + + Send + + Sync; + +#[cfg(test)] +pub(super) type TestSpawner = dyn Fn(WorkerTask) -> io::Result> + Send + Sync; + +#[cfg(test)] +pub(super) type TestClock = dyn Fn() -> Instant + Send + Sync; + +#[cfg(test)] +pub(super) type TestWarning = dyn Fn(&io::Error) + Send + Sync; + +#[cfg(test)] +pub(super) struct TestHooks { + pub(super) spawner: Arc, + pub(super) executor: Arc, + pub(super) now: Arc, + pub(super) on_warning: Arc, +} diff --git a/src/git/diff/load_worker/tests.rs b/src/git/diff/load_worker/tests.rs new file mode 100644 index 00000000..433abcd3 --- /dev/null +++ b/src/git/diff/load_worker/tests.rs @@ -0,0 +1,233 @@ +use std::io; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use super::runtime::{TestHooks, WorkerTask, WorkerThread, spawn_task}; +use super::*; + +mod retry; + +fn request(generation: u64, operation: GitLoadOperation) -> GitLoadRequest { + GitLoadRequest { + repo: "repo".into(), + generation, + operation, + } +} + +#[test] +fn 같은_lane의_대기_요청은_최신_요청_하나로_합쳐진다() { + let mut pending = Pending::default(); + for generation in 1..=100_000 { + pending.replace(request( + generation, + GitLoadOperation::StatusDiff(format!("{generation}.rs")), + )); + } + + let latest = pending.take_next().unwrap(); + assert_eq!(latest.generation, 100_000); + assert!(pending.take_next().is_none()); +} + +#[test] +fn 서로_다른_lane의_요청은_서로를_덮어쓰지_않는다() { + let mut pending = Pending::default(); + pending.replace(request(1, GitLoadOperation::StatusDiff("a.rs".into()))); + pending.replace(request(2, GitLoadOperation::WorkdirFile("a.rs".into()))); + + assert!(pending.take_next().is_some()); + assert!(pending.take_next().is_some()); +} + +#[test] +fn continuously_refilled_diff_lane_cannot_starve_other_lanes() { + let mut pending = Pending::default(); + pending.replace(request(1, GitLoadOperation::StatusDiff("a.rs".into()))); + pending.replace(request(2, GitLoadOperation::WorkdirFile("a.rs".into()))); + pending.replace(request(3, GitLoadOperation::CommitFiles(Oid::ZERO_SHA1))); + pending.replace(request(4, GitLoadOperation::Decorations)); + + let mut lanes = Vec::new(); + for generation in 5..9 { + let next = pending.take_next().unwrap(); + lanes.push(next.operation.lane()); + pending.replace(request( + generation, + GitLoadOperation::StatusDiff(format!("{generation}.rs")), + )); + } + + assert!(lanes.contains(&LoadLane::File)); + assert!(lanes.contains(&LoadLane::CommitFiles)); + assert!(lanes.contains(&LoadLane::Decorations)); +} + +#[test] +fn finished_panicked_worker_restarts_and_completes_queued_request() { + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let worker = worker_with_hooks( + { + let spawn_calls = Arc::clone(&spawn_calls); + move |task| { + if spawn_calls.fetch_add(1, Ordering::SeqCst) == 0 { + return thread::Builder::new().spawn(move || { + drop(task); + panic!("injected worker panic"); + }); + } + spawn_task(task) + } + }, + successful_executor, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("after-panic.rs".into()), + )); + + assert_file_reply(wait_for_reply(&worker), "after-panic.rs"); + assert!(spawn_calls.load(Ordering::SeqCst) >= 2); +} + +#[test] +fn queued_request_completes_after_injected_spawn_failure_and_poll_retry() { + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let worker = worker_with_hooks( + { + let spawn_calls = Arc::clone(&spawn_calls); + move |task| { + if spawn_calls.fetch_add(1, Ordering::SeqCst) < 2 { + return Err(io::Error::other("injected spawn failure")); + } + spawn_task(task) + } + }, + successful_executor, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("after-spawn-failure.rs".into()), + )); + + assert_file_reply(wait_for_reply(&worker), "after-spawn-failure.rs"); + assert!(spawn_calls.load(Ordering::SeqCst) >= 3); +} + +#[test] +fn task_panic_replies_with_error_and_worker_completes_future_request() { + let execute_calls = Arc::new(AtomicUsize::new(0)); + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let worker = worker_with_hooks( + { + let spawn_calls = Arc::clone(&spawn_calls); + move |task| { + spawn_calls.fetch_add(1, Ordering::SeqCst); + spawn_task(task) + } + }, + { + let execute_calls = Arc::clone(&execute_calls); + move |request, cached| { + if execute_calls.fetch_add(1, Ordering::SeqCst) == 0 { + panic!("injected task panic"); + } + successful_executor(request, cached) + } + }, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("panics.rs".into()), + )); + let panic_reply = wait_for_reply(&worker); + assert_eq!(panic_reply.request.generation, 1); + assert_eq!( + panic_reply.result.err().as_deref(), + Some("background git load panicked") + ); + + worker.submit(request( + 2, + GitLoadOperation::WorkdirFile("after-task-panic.rs".into()), + )); + assert_file_reply(wait_for_reply(&worker), "after-task-panic.rs"); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 1); +} + +fn worker_with_hooks( + spawner: impl Fn(WorkerTask) -> io::Result> + Send + Sync + 'static, + executor: impl Fn( + &GitLoadRequest, + &mut Option<(String, git2::Repository)>, + ) -> anyhow::Result + + Send + + Sync + + 'static, +) -> GitLoadWorker { + let hooks = Arc::new(TestHooks { + spawner: Arc::new(spawner), + executor: Arc::new(executor), + now: Arc::new(Instant::now), + on_warning: Arc::new(|_| {}), + }); + GitLoadWorker::new(move |reply_tx| WorkerThread::with_hooks(reply_tx, hooks)) +} + +struct ManualClock { + start: Instant, + elapsed_ms: AtomicU64, +} + +impl ManualClock { + fn new() -> Self { + Self { + start: Instant::now(), + elapsed_ms: AtomicU64::new(0), + } + } + + fn now(&self) -> Instant { + self.start + Duration::from_millis(self.elapsed_ms.load(Ordering::SeqCst)) + } + + fn advance(&self, duration: Duration) { + let millis = u64::try_from(duration.as_millis()).expect("test duration fits u64"); + self.elapsed_ms.fetch_add(millis, Ordering::SeqCst); + } +} + +fn successful_executor( + request: &GitLoadRequest, + _: &mut Option<(String, git2::Repository)>, +) -> anyhow::Result { + let GitLoadOperation::WorkdirFile(path) = &request.operation else { + panic!("test executor only accepts workdir file loads"); + }; + Ok(GitLoadPayload::File(path.clone())) +} + +fn wait_for_reply(worker: &GitLoadWorker) -> GitLoadReply { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + match worker.try_recv() { + Ok(reply) => return reply, + Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => thread::yield_now(), + Err(mpsc::TryRecvError::Empty) => panic!("worker did not reply before the deadline"), + Err(mpsc::TryRecvError::Disconnected) => panic!("worker reply channel disconnected"), + } + } +} + +fn assert_file_reply(reply: GitLoadReply, expected: &str) { + match reply.result { + Ok(GitLoadPayload::File(content)) => assert_eq!(content, expected), + Ok(_) => panic!("worker returned the wrong payload kind"), + Err(error) => panic!("worker returned an error: {error}"), + } +} diff --git a/src/git/diff/load_worker/tests/retry.rs b/src/git/diff/load_worker/tests/retry.rs new file mode 100644 index 00000000..64fe7fa6 --- /dev/null +++ b/src/git/diff/load_worker/tests/retry.rs @@ -0,0 +1,157 @@ +use std::io; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use super::super::runtime::{TestHooks, WorkerTask, WorkerThread, spawn_task}; +use super::super::*; +use super::{ManualClock, assert_file_reply, request, successful_executor, wait_for_reply}; + +#[test] +fn transient_spawn_failures_recover_after_bounded_backoff() { + let clock = Arc::new(ManualClock::new()); + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let worker = controlled_worker( + Arc::clone(&clock), + { + let spawn_calls = Arc::clone(&spawn_calls); + move |task| { + if spawn_calls.fetch_add(1, Ordering::SeqCst) < 2 { + return Err(io::Error::other("injected spawn failure")); + } + spawn_task(task) + } + }, + |_| {}, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("after-backoff.rs".into()), + )); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 1); + + clock.advance(Duration::from_millis(15)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 1); + + clock.advance(Duration::from_millis(1)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 2); + + clock.advance(Duration::from_millis(31)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 2); + + clock.advance(Duration::from_millis(1)); + assert_file_reply(wait_for_reply(&worker), "after-backoff.rs"); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 3); +} + +#[test] +fn sustained_spawn_failure_bounds_retries_and_warnings() { + let clock = Arc::new(ManualClock::new()); + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let warning_calls = Arc::new(AtomicUsize::new(0)); + let worker = controlled_worker( + Arc::clone(&clock), + { + let spawn_calls = Arc::clone(&spawn_calls); + move |_| { + spawn_calls.fetch_add(1, Ordering::SeqCst); + Err(io::Error::other("persistent spawn failure")) + } + }, + { + let warning_calls = Arc::clone(&warning_calls); + move |_| { + warning_calls.fetch_add(1, Ordering::SeqCst); + } + }, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("still-pending.rs".into()), + )); + for _ in 0..3_750 { + clock.advance(Duration::from_millis(16)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + } + + assert_eq!(spawn_calls.load(Ordering::SeqCst), 65); + assert_eq!(warning_calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn successful_spawn_resets_backoff_and_warning_window() { + let clock = Arc::new(ManualClock::new()); + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let warning_calls = Arc::new(AtomicUsize::new(0)); + let successful_worker_exited = Arc::new(AtomicBool::new(false)); + let worker = controlled_worker( + Arc::clone(&clock), + { + let spawn_calls = Arc::clone(&spawn_calls); + let successful_worker_exited = Arc::clone(&successful_worker_exited); + move |task| match spawn_calls.fetch_add(1, Ordering::SeqCst) { + 0 | 2 => Err(io::Error::other("injected spawn failure")), + 1 => { + let successful_worker_exited = Arc::clone(&successful_worker_exited); + thread::Builder::new().spawn(move || { + drop(task); + successful_worker_exited.store(true, Ordering::SeqCst); + }) + } + _ => spawn_task(task), + } + }, + { + let warning_calls = Arc::clone(&warning_calls); + move |_| { + warning_calls.fetch_add(1, Ordering::SeqCst); + } + }, + ); + + worker.submit(request( + 1, + GitLoadOperation::WorkdirFile("after-reset.rs".into()), + )); + clock.advance(Duration::from_millis(16)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + while !successful_worker_exited.load(Ordering::SeqCst) { + thread::yield_now(); + } + + let deadline = Instant::now() + Duration::from_secs(1); + while spawn_calls.load(Ordering::SeqCst) < 3 && Instant::now() < deadline { + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + thread::yield_now(); + } + assert_eq!(spawn_calls.load(Ordering::SeqCst), 3); + assert_eq!(warning_calls.load(Ordering::SeqCst), 2); + + clock.advance(Duration::from_millis(15)); + assert!(matches!(worker.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 3); + + clock.advance(Duration::from_millis(1)); + assert_file_reply(wait_for_reply(&worker), "after-reset.rs"); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 4); +} + +fn controlled_worker( + clock: Arc, + spawner: impl Fn(WorkerTask) -> io::Result> + Send + Sync + 'static, + on_warning: impl Fn(&io::Error) + Send + Sync + 'static, +) -> GitLoadWorker { + let hooks = Arc::new(TestHooks { + spawner: Arc::new(spawner), + executor: Arc::new(successful_executor), + now: Arc::new(move || clock.now()), + on_warning: Arc::new(on_warning), + }); + GitLoadWorker::new(move |reply_tx| WorkerThread::with_hooks(reply_tx, hooks)) +} diff --git a/src/git/diff/refs.rs b/src/git/diff/refs.rs index c20acb1b..43a22398 100644 --- a/src/git/diff/refs.rs +++ b/src/git/diff/refs.rs @@ -3,10 +3,9 @@ use git2::{Oid, Repository}; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; -/// Upper bound on the oids collected per divergence side. A repository can -/// diverge from its upstream by an arbitrary number of commits, and the sets -/// exist only to mark rows the user can actually scroll to; the walk yields -/// newest-first, so the cap drops the far tail rather than the visible head. +/// Upper bound on oids collected per divergence side: the walk yields +/// newest-first, so capping drops the far tail, not the rows a user can +/// actually scroll to. const MAX_DIVERGENCE_OIDS: usize = 1_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -26,11 +25,9 @@ pub struct RefLabel { pub name: String, } -/// Everything the commit log needs to decorate rows: which refs point at which -/// commit, and which commits are ahead of / behind the upstream. -/// -/// Built from refs alone, so it stays valid until a ref moves. Callers rebuild -/// it when [`refs_fingerprint`] changes rather than per frame or per poll. +/// Decorations for the commit log: which refs point at which commit, and +/// which commits are ahead of / behind the upstream. Built from refs alone, so +/// callers rebuild it when [`refs_fingerprint`] changes rather than per frame. #[derive(Debug, Default)] pub struct LogDecorations { labels: HashMap>, @@ -57,7 +54,7 @@ impl LogDecorations { } } -/// Cheap summary of every ref's name and target, used to decide whether +/// Cheap summary of every ref's name and target, to decide whether /// [`load_log_decorations`] needs to run again. A fetch that advances /// `origin/dev` changes this even though HEAD did not move. pub fn refs_fingerprint(repo: &Repository) -> u64 { @@ -156,10 +153,8 @@ pub fn load_log_decorations(repo: &Repository) -> Result { }) } -/// Oids that exist on exactly one side of the HEAD/upstream split. -/// -/// `None` when HEAD is detached, unborn, or has no upstream — there is nothing -/// to diverge from, which is not an error. +/// Oids on exactly one side of the HEAD/upstream split. `None` when HEAD is +/// detached, unborn, or has no upstream — nothing to diverge from, not an error. fn divergence_oids(repo: &Repository) -> Option<(HashSet, HashSet)> { let head = repo.head().ok()?; if !head.is_branch() { diff --git a/src/git/diff/snapshot.rs b/src/git/diff/snapshot.rs index b88412a4..086fbca7 100644 --- a/src/git/diff/snapshot.rs +++ b/src/git/diff/snapshot.rs @@ -29,10 +29,9 @@ pub fn load_snapshot(repo: &Repository) -> Result { .statuses(Some(&mut opts)) .context("failed to get repository status")?; - // Keyed by effective (new-side) path so the file list stays in a stable - // sorted order across refreshes — selection restoration depends on that. - // Each git status entry already carries both X and Y bits, so there is no - // longer a first-wins collapse: one entry maps to one row. + // Keyed by effective (new-side) path: one entry maps to one row, and the + // stable sorted order across refreshes is what selection restoration + // depends on. let mut files = BTreeMap::new(); for entry in statuses.iter() { let Some((index, worktree)) = status_columns(entry.status()) else { @@ -69,16 +68,13 @@ pub fn load_snapshot(repo: &Repository) -> Result { } /// Map a git2 status bitset into separate index (X) and worktree (Y) columns. -/// Untracked and conflicted are reported as both-column sentinels so the -/// renderer can collapse them to `??` / `UU`. Returns `None` when neither -/// column carries a displayable change. +/// Untracked and conflicted are sentinels spanning both columns so the +/// renderer can show `??` / `UU`. `None` when neither column displays a change. fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { - // Untracked: git renders `??` (both columns), not ` ?`. Only a *purely* - // untracked entry collapses to `??`. A combined state such as - // `INDEX_DELETED | WT_NEW` (staged deletion, then a fresh file recreated at - // the same path) keeps its index status so the staged change is not hidden; - // git itself emits two rows there, but our one-row-per-path model preserves - // the index side (`D `) rather than masking it as untracked. + // Only a *purely* untracked entry collapses to `??`. A combined state such + // as `INDEX_DELETED | WT_NEW` keeps its index status so the staged change + // is not hidden; git emits two rows there, but the one-row-per-path model + // preserves the index side instead of masking it as untracked. let index_bits = Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED @@ -116,8 +112,7 @@ fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { } else if status.contains(Status::WT_TYPECHANGE) { StatusKind::TypeChanged } else if status.contains(Status::WT_UNREADABLE) { - // No standard git short code; keep it visible as a worktree change - // rather than dropping the row (preserves prior behavior). + // No standard short code exists; keep the row visible as a change. StatusKind::Modified } else { StatusKind::Unmodified @@ -129,9 +124,8 @@ fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { Some((index, worktree)) } -/// Effective (new-side) path plus the old path for renames. The effective -/// path drives diff/file loading; `old_path` is display/search metadata only -/// and is omitted when it equals the effective path. +/// Effective (new-side) path plus the old path for renames. `old_path` is +/// display/search metadata only, omitted when it equals the effective path. fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option)> { let i2w = entry.index_to_workdir(); let h2i = entry.head_to_index(); @@ -144,10 +138,10 @@ fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option new` string. /// Returns `Cow` so callers can slice it for horizontal scroll via - /// `char_offset` and measure it with `chars().count()`. + /// `char_offset`. pub fn display_path(&self) -> Cow<'_, str> { match &self.old_path { Some(old) => Cow::Owned(format!("{old} -> {}", self.path)), @@ -175,9 +175,8 @@ pub struct RepoSnapshot { pub files: Vec, pub tracking: Option, /// HEAD commit oid at snapshot time. `None` when HEAD is unborn (an empty - /// repository, an orphan checkout) or unreadable — a detached HEAD still - /// names a commit. Compared against `App::last_head_oid` to detect new - /// commits. + /// repository, an orphan checkout) or unreadable. Compared against + /// `App::last_head_oid` to detect new commits. pub head_oid: Option, /// Current branch shorthand (e.g. `main`). `None` for detached HEAD, /// unborn branch, or bare repo. diff --git a/src/git/mod.rs b/src/git/mod.rs index 257036d3..b1f74225 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -17,34 +17,29 @@ pub fn resolve_repo_path(path: impl AsRef) -> PathBuf { .and_then(|repo| repo.workdir().map(Path::to_path_buf)); let candidate = found.as_deref().unwrap_or(path); // Canonicalized whichever branch produced it, so one worktree has exactly - // one spelling. Project de-duplication compares these strings, and a - // second spelling opens a second tab on a repository already open. + // one spelling: project de-duplication compares these strings, and a second + // spelling opens a second tab on a repository already open. // // Applied to libgit2's answer too, rather than trusting it: what `workdir` // returns is platform-specific — a trailing separator, symlinks resolved on // some systems and not others, and on Windows the casing as it was asked - // for rather than as it is on disk, where `C:\Code` and `c:\code` are one - // directory. Making the guarantee ours costs one `stat` and does not depend - // on behaviour no test here can reach. + // for rather than as it is on disk. Making the guarantee ours costs one + // `stat` and does not depend on behaviour no test here can reach. // - // A path that cannot be canonicalized is returned as it came. That is - // almost always one that does not exist, which the caller has already - // rejected — but a directory the process cannot open would land here too, - // and for it the single-spelling guarantee is off. Opening the repository - // and letting git report what is wrong beats refusing to show it at all, - // and the cost of being wrong is the duplicate tab this exists to prevent, - // not anything lost. + // A path that cannot be canonicalized is returned as it came — almost + // always one that does not exist, which the caller has already rejected. + // For it the single-spelling guarantee is off, but opening the repository + // and letting git report what is wrong beats refusing to show it at all. crate::platform::paths::canonicalize_clean(candidate) .unwrap_or_else(|_| candidate.to_path_buf()) } /// Format a `git2::Error` from `Repository::discover` for user-facing display. /// -/// When the error is a "not a repository" / `NotFound` error of class -/// `Repository`, the internal libgit2 diagnostic (`; class=Repository (6); -/// code=NotFound (-3)`) is stripped — users cannot act on it. All other -/// errors preserve the full `error.to_string()` so the diagnostic is -/// available for debugging. +/// A "not a repository" / `NotFound` error of class `Repository` loses the +/// internal libgit2 diagnostic (`; class=Repository (6); code=NotFound (-3)`) — +/// users cannot act on it. All other errors keep the full `error.to_string()` +/// for debugging. pub fn format_discover_error(error: &git2::Error) -> String { if error.class() == git2::ErrorClass::Repository && error.code() == git2::ErrorCode::NotFound { error.message().to_string() diff --git a/src/git/path/mod.rs b/src/git/path/mod.rs index 6fe316f1..54797d79 100644 --- a/src/git/path/mod.rs +++ b/src/git/path/mod.rs @@ -1,7 +1,7 @@ //! Validation for repository-relative paths that reach the filesystem. //! -//! Every path that names a file inside a worktree goes through -//! [`resolve_in_workdir`] before being opened. The web surfaces route +//! Every path naming a file inside a worktree goes through +//! [`resolve_in_workdir`] before being opened: the web surfaces route //! caller-supplied strings to the same loaders, so the check lives at the //! filesystem boundary rather than at each call site. @@ -34,27 +34,25 @@ const HFS_IGNORABLE: [char; 16] = [ /// The name a filesystem will actually open, given the name that was asked for. /// -/// Three rewrites, each a documented way to name one file and be handed -/// another, and each defended by git too (`core.protectNTFS`, -/// `core.protectHFS`) — though not identically: git tests *every* colon- -/// delimited segment of a name and this tests the first. The difference is -/// unreachable, because a later segment names a stream hanging off the earlier -/// one rather than a directory, so `x:.git` is a stream on `x` and never git's -/// own directory. +/// Undoes the three documented ways to name one file and be handed another, +/// each also defended by git (`core.protectNTFS`, `core.protectHFS`): /// /// - everything from a `:` on is an NTFS alternate-stream suffix, and /// `.git::$INDEX_ALLOCATION` opens the directory `.git` /// - HFS+ drops the ignorable code points above, so `.git` is `.git` /// - Windows drops trailing dots and spaces, so `.git.` is `.git` /// +/// Only the first `:`-delimited segment is tested, unlike git which tests every +/// one — the difference is unreachable because a later segment names a stream +/// hanging off the earlier one (`x:.git` is a stream on `x`), never a directory. +/// /// Applied to every component before *any* rule judges it, so a rewritten name /// cannot slip past `..` either — on HFS+ a `.` with an ignorable between the /// dots is still the parent directory. fn effective_name(name: &str) -> String { // Only when something precedes it: a stream suffix hangs off a name, so a - // leading `:` is not one. Cutting there unconditionally left nothing to - // judge, and `:f.rs` — an ordinary file on Unix, unnameable on Windows — - // came back as a traversal. + // leading `:` is not one. Cutting there unconditionally reduced `:f.rs` — + // an ordinary file on Unix — to nothing and called it a traversal. let base = match name.split(':').next() { Some(before) if !before.is_empty() => before, _ => name, @@ -70,8 +68,8 @@ fn effective_name(name: &str) -> String { /// run on: case-insensitively (macOS, Windows), under every rewrite /// [`effective_name`] undoes, and including the 8.3 short name. /// -/// Every place that decides whether a name is git's own directory must use this -/// — a second, looser spelling of the rule is how a bypass gets in. +/// Every place that decides whether a name is git's own directory must use +/// this — a second, looser spelling of the rule is how a bypass gets in. pub fn is_git_dir_name(name: &str) -> bool { let name = effective_name(name); name.eq_ignore_ascii_case(GIT_DIR) || name.eq_ignore_ascii_case(GIT_SHORT_DIR) @@ -88,11 +86,8 @@ fn is_git_dir(part: &std::ffi::OsStr) -> bool { /// Windows reads `c:x` as drive `C:` plus `x`, but only at the start of a path — /// so as the second component of `src/c:x` it arrives here as one `Normal`. /// `PathBuf::push` then parses it again, finds the prefix, and *replaces the -/// whole buffer*, throwing away the worktree the walk had built up. Refusing it -/// keeps the component walk honest instead of leaning on the final containment -/// check to notice. -/// -/// A no-op on Unix, where a colon is an ordinary character in a name. +/// whole buffer*, throwing away the worktree the walk had built up. A no-op on +/// Unix, where a colon is an ordinary character in a name. fn is_a_path_of_its_own(part: &std::ffi::OsStr) -> bool { let mut components = Path::new(part).components(); !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() @@ -103,10 +98,8 @@ fn is_a_path_of_its_own(part: &std::ffi::OsStr) -> bool { /// /// `Path::components` judges the name as written, so `.. ` on Windows and /// `..` on HFS+ both arrive here as one `Normal` and the `..` arm below -/// never runs. The escape this module exists to stop would then be spelled with -/// one extra character. -/// -/// It costs a name like `...`, legal on Unix and unnameable on Windows anyway. +/// never runs. It costs a name like `...`, legal on Unix and unnameable on +/// Windows anyway. fn is_traversal_after_trimming(part: &std::ffi::OsStr) -> bool { part.to_str().is_some_and(|name| { let name = effective_name(name); @@ -116,12 +109,10 @@ fn is_traversal_after_trimming(part: &std::ffi::OsStr) -> bool { /// True when no path may contain `name` as a component. /// -/// The listing surfaces share this with the validator so that no row is offered -/// under a name the gate will refuse: the file tree used to show a `...` -/// directory that the gate then refused, and search silently dropped everything -/// under it. Names only — whether the thing behind the name can be opened is -/// [`resolve_in_workdir`]'s question, and it still refuses a symlink that is -/// listed. +/// The listing surfaces share this with the validator so no row is offered under +/// a name the gate will refuse (a `...` row that answered "not a plain relative +/// path" when clicked used to happen). Names only — whether the thing behind the +/// name can be opened is [`resolve_in_workdir`]'s question. pub fn is_refused_component(name: &str) -> bool { let part = std::ffi::OsStr::new(name); is_git_dir_name(name) || is_traversal_after_trimming(part) || is_a_path_of_its_own(part) @@ -131,8 +122,8 @@ pub fn is_refused_component(name: &str) -> bool { /// /// Unlike [`resolve_in_workdir`], this deliberately does not stat the path: /// a deleted file is absent from the current worktree but is still a valid -/// member of a historical commit diff. Callers must use this only with git's -/// object database, never before opening a worktree file. +/// member of a historical commit diff. Never use this before opening a +/// worktree file. pub fn validate_commit_path(relative: &str) -> Result<()> { if relative.is_empty() { return Err(anyhow!("empty path")); @@ -160,15 +151,14 @@ pub fn validate_commit_path(relative: &str) -> Result<()> { } /// Resolve `relative` against `workdir`, rejecting anything that could escape -/// the worktree or read git's internals. -/// -/// Rejects: absolute paths, `..` and other non-plain components, any component -/// naming the git directory (see [`is_git_dir`]), embedded NUL bytes, and -/// symlinks at *any* component — not just the final one. +/// the worktree or read git's internals: absolute paths, `..` and other +/// non-plain components, any component naming the git directory (see +/// [`is_git_dir`]), embedded NUL bytes, and symlinks at *any* component — not +/// just the final one. /// -/// The returned path is the canonicalized location and is guaranteed to sit -/// under the canonicalized `workdir`. A caller that opens it still races with -/// a concurrent rename of the worktree itself; that residual TOCTOU window is +/// The returned path is canonicalized and guaranteed to sit under the +/// canonicalized `workdir`. A caller that opens it still races with a +/// concurrent rename of the worktree itself; that residual TOCTOU window is /// accepted, since every surface reaching this function is already /// authenticated and local. pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { @@ -203,8 +193,8 @@ pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { // Not redundant, even though the walk rejected every link: `push` re-parses // each component, and one that carries a Windows prefix would replace the - // buffer outright rather than extend it. `is_a_path_of_its_own` refuses - // those up front, and this is what catches it if a spelling gets past. + // buffer outright rather than extend it — `is_a_path_of_its_own` refuses + // those up front, and this is the backstop. if !resolved.starts_with(&base) { return Err(anyhow!("path escapes the worktree: {relative}")); } diff --git a/src/git/tree/mod.rs b/src/git/tree/mod.rs index 12e06e03..f5204ad5 100644 --- a/src/git/tree/mod.rs +++ b/src/git/tree/mod.rs @@ -18,12 +18,12 @@ pub struct TreeEntry { } /// Read the immediate children of `rel_dir` (a repo-relative path; `""` is the -/// workdir root). Entries are filtered and returned sorted with directories -/// first, then case-sensitive alphabetical by name. +/// workdir root), filtered and sorted with directories first, then +/// case-sensitive alphabetical by name. /// /// `.git` is skipped at every level. Non-UTF-8 names are skipped because the -/// file-view loader keys on `&str` paths. Individual entries whose metadata -/// cannot be read are skipped rather than failing the whole listing. +/// file-view loader keys on `&str` paths. Entries whose metadata cannot be read +/// are skipped rather than failing the whole listing. pub fn read_children( repo: &Repository, workdir: &Path, @@ -63,11 +63,8 @@ pub fn read_children( // request can carry. Sharing the rule is the point — an exact // `== ".git"` here would still list `.GIT` on a case-insensitive // filesystem, and listing a `...` directory left a row that answered - // "not a plain relative path" when clicked. - // - // Only names. A symlink still gets a row and refuses to open, because - // that is the open gate's own rule and nothing about how this name is - // spelled. + // "not a plain relative path" when clicked. Only names: a symlink still + // gets a row and refuses to open. if crate::git::path::is_refused_component(&name) { continue; } @@ -97,8 +94,7 @@ pub fn read_children( Ok(out) } -/// One hit from [`search_tree`]: the full repo-relative path and whether the -/// entry is a directory. +/// One hit from [`search_tree`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TreeMatch { pub path: String, diff --git a/src/input/encode.rs b/src/input/encode.rs index d14336f9..6ee4bcd1 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -11,10 +11,9 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { match key.code { KeyCode::Char(c) => { if ctrl && c.is_ascii() { - // Several Ctrl chords fall outside the contiguous - // `c.to_ascii_uppercase() - '@' < 32` range and need explicit - // xterm-convention mappings: Ctrl+Space → NUL (formula wraps - // because ' ' < '@'), Ctrl+/ → 0x1F (US), Ctrl+? → 0x7F (DEL). + // Ctrl chords outside the `letter - '@'` formula need explicit + // xterm-convention mappings: Ctrl+Space → NUL (the formula + // wraps because ' ' < '@'), Ctrl+/ → 0x1F (US), Ctrl+? → 0x7F. let b = match c { ' ' => Some(0x00), '/' => Some(0x1F), @@ -39,16 +38,13 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { let mut enc = [0u8; 4]; Some(c.encode_utf8(&mut enc).as_bytes().to_vec()) } - // Alt+Enter carries the Meta prefix like Alt+Char does. Terminal UIs - // read ESC+CR as "insert a newline, don't submit" — it is what Claude - // Code binds its newline to — so dropping the modifier here made the - // two indistinguishable and every Alt+Enter submitted instead. - // - // Ctrl+Enter is LF for the same reason. A terminal that cannot tell the - // chord from a bare Enter sends Ctrl+J (LF) for it and nightcrow never - // sees the modifier; one that can — the Windows console API, the kitty - // keyboard protocol — delivers `Enter + CONTROL`, and encoding that as - // CR submitted the line on exactly the platforms that report it. + // Alt+Enter carries the Meta prefix like Alt+Char does: terminal UIs + // read ESC+CR as "insert a newline, don't submit" (Claude Code binds + // its newline to it), so dropping the modifier made every Alt+Enter + // submit instead. Ctrl+Enter is LF for the same reason — a terminal + // that can report the modifier delivers `Enter + CONTROL`, and + // encoding that as CR would submit the line on exactly those + // platforms. KeyCode::Enter => { let byte = if ctrl { b'\n' } else { b'\r' }; Some(if alt { vec![0x1b, byte] } else { vec![byte] }) @@ -75,10 +71,9 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { /// Bit 6 (64) marks the button as a wheel rather than a click. const SGR_WHEEL_UP: u8 = 64; -/// Encode a mouse wheel notch as an SGR (1006) mouse report. `col`/`row` are -/// 1-based cell coordinates. A wheel notch has no release event, so a single -/// `M` (press) report is the whole sequence — unlike a click, which xterm -/// follows with an `m`. +/// Encode a mouse wheel notch as an SGR (1006) mouse report; `col`/`row` are +/// 1-based cells. A wheel notch has no release event, so a single `M` (press) +/// report is the whole sequence — unlike a click, which xterm follows with `m`. pub fn encode_wheel(up: bool, col: u16, row: u16) -> Vec { let button = if up { SGR_WHEEL_UP } else { SGR_WHEEL_UP + 1 }; format!("\x1b[<{button};{};{}M", col.max(1), row.max(1)).into_bytes() @@ -96,10 +91,10 @@ pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { format!("\x1b[<{button};{};{}M", col.max(1), row.max(1)).into_bytes() } -/// Encode a mouse button press or release as an SGR (1006) mouse report. -/// `col`/`row` are 1-based pane-local cell coordinates. SGR keeps the real -/// button code on release and marks it with a final `m` instead of `M` — -/// unlike legacy X10, which collapses every release to button 3. +/// Encode a mouse button press or release as an SGR (1006) mouse report with +/// 1-based pane-local cell coordinates. SGR keeps the real button code on +/// release and marks it with a final `m` instead of `M` — unlike legacy X10, +/// which collapses every release to button 3. pub fn encode_button(button: MouseButton, press: bool, col: u16, row: u16) -> Vec { let code: u8 = match button { MouseButton::Left => 0, diff --git a/src/input/routing.rs b/src/input/routing.rs index 6b209266..64e5d2c3 100644 --- a/src/input/routing.rs +++ b/src/input/routing.rs @@ -37,12 +37,12 @@ pub fn map_key(event: KeyEvent) -> Action { } } -/// Classify the single follow-up key pressed after the leader. The follow-up -/// is matched on the bare character regardless of modifiers so ` t` works -/// whether or not the user is still holding a modifier from the leader chord. -/// The digit row addresses whatever the body is showing: `1` = file list, -/// `2` = diff viewer, `3`..`9`,`0` = terminal panes `0`..`7`. The bare F-keys -/// are a separate axis (project tabs), so the two never collide. +/// Classify the leader follow-up key. Matched on the bare character +/// regardless of modifiers so ` t` works whether or not the user is still +/// holding a modifier from the leader chord. The digit row addresses whatever +/// the body is showing (`1` = file list, `2` = diff viewer, `3`..`9`,`0` = +/// panes `0`..`7`); the bare F-keys are a separate axis (project tabs), so +/// the two never collide. pub fn prefix_action(event: KeyEvent) -> Action { match event.code { KeyCode::Char(c) => match c.to_ascii_lowercase() { @@ -74,12 +74,11 @@ pub fn prefix_action(event: KeyEvent) -> Action { } /// Leader follow-up mapping while the terminal fills the body -/// (`TerminalFullscreen::fills_body`). The upper viewer is hidden, so the -/// digit row is repurposed: `1`..`8` address the (up to -/// `MAX_VISIBLE_FULLSCREEN` = 8) terminal panes `0`..`7` by natural -/// numbering instead of the list/diff focus jumps. `9`/`0` have no pane in -/// the 8-pane cap and are dropped rather than falling through to the -/// split-view bindings. Every non-digit chord behaves as in `prefix_action`. +/// (`TerminalFullscreen::fills_body`): the upper viewer is hidden, so the +/// digit row is repurposed onto panes `0`..`7` by natural numbering instead +/// of the list/diff focus jumps. `9`/`0` address no pane within the 8-pane +/// cap and are dropped rather than falling through to the split-view +/// bindings. Every non-digit chord behaves as in `prefix_action`. pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { if let KeyCode::Char(c @ '0'..='9') = event.code { return match c { diff --git a/src/platform/logging.rs b/src/platform/logging.rs index 0588af2c..806050b2 100644 --- a/src/platform/logging.rs +++ b/src/platform/logging.rs @@ -92,16 +92,13 @@ pub fn init_logging(config: &LogConfig, repo_path: &str) -> Option { } /// Drop a self-ignoring `.gitignore` in the log directory so logs never -/// pollute the user's `git status` — the default `.nightcrow/logs` sits inside -/// the repo. +/// pollute the user's `git status` — the default `.nightcrow/logs` sits +/// inside the repo. /// -/// Only into a directory nightcrow owns, meaning one under `.nightcrow`. The -/// pattern is `*`, which has to ignore the ignore file itself to hide the -/// directory — harmless in our own folder, but writing that into a directory -/// the user pointed `[log] dir` at would make Git ignore everything untracked -/// there. A custom location is the user's to manage. -/// -/// Only written when missing: a user-edited file should not be clobbered. +/// Only into a directory nightcrow owns (one under `.nightcrow`): the `*` +/// pattern ignores the directory's every untracked file, which would be +/// wrong for a user-chosen `[log] dir` — that one is the user's to manage. +/// Only written when missing, so a user-edited file is not clobbered. fn write_log_gitignore(log_dir: &Path) { if !log_dir.components().any(|c| c.as_os_str() == NIGHTCROW_DIR) { return; @@ -135,10 +132,10 @@ fn cleanup_old_logs(log_dir: &Path, max_days: u32) { return; }; - // First pass: collect candidate files with mtimes so we can identify the - // newest one and preserve it. SizeRollingAppender resumes its highest - // existing index on startup, so the latest log file may itself be older - // than the cutoff — deleting it would lose the active session's tail. + // First pass: collect candidate files with mtimes so the newest one can + // be preserved — SizeRollingAppender resumes its highest existing index + // on startup, so the latest log file may itself be older than the + // cutoff, and deleting it would lose the active session's tail. let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new(); for entry in entries.flatten() { let path = entry.path(); @@ -160,12 +157,11 @@ fn cleanup_old_logs(log_dir: &Path, max_days: u32) { } /// Returns paths to delete from a list of candidate `(path, mtime)` entries. -/// Always preserves the newest entry, even if it is older than the cutoff — +/// Always preserves the newest entry, even if older than the cutoff — /// SizeRollingAppender resumes the highest-index file, so deleting it would /// drop the active session's tail. When two candidates share the maximum -/// mtime (1 s mtime granularity on FAT/exFAT, simultaneous touches, etc.), -/// only the first occurrence is preserved; the others remain eligible for -/// cleanup so a tie doesn't silently inflate disk usage. +/// mtime (1 s granularity on FAT/exFAT, simultaneous touches), only the first +/// is preserved; the others stay eligible so a tie doesn't inflate disk usage. fn expired_log_paths(candidates: &[(PathBuf, SystemTime)], cutoff: SystemTime) -> Vec<&PathBuf> { let newest_idx = candidates .iter() diff --git a/src/platform/signals.rs b/src/platform/signals.rs index 951104cb..114e6f81 100644 --- a/src/platform/signals.rs +++ b/src/platform/signals.rs @@ -94,13 +94,14 @@ mod imp { } } - /// Windows 는 시그널 대신 콘솔 제어 이벤트를 쓴다. 콜백을 채널로 옮겨 - /// register/wait 분리를 Unix 와 동일하게 유지한다 — 등록 시점부터 도착한 - /// 이벤트가 wait 까지 보관되어야 하고, 그게 이 계약의 요점이다. + /// Windows signals via console control events rather than POSIX signals; + /// the callback's event is moved onto a channel so register/wait split + /// stays the same contract as Unix — an event that arrives between + /// register and wait must be held for `wait`. /// - /// SIGTERM 대응물이 없다. Ctrl-C 와 Ctrl-Break 는 콘솔이 붙어 있을 때만 - /// 오고, `-d` 로 분리된 daemon 에는 콘솔이 없다 (detach.rs 참조). - /// 그쪽 종료 경로는 `nightcrow stop` 이다. + /// There is no SIGTERM counterpart: Ctrl-C and Ctrl-Break only exist with + /// a console attached, and a daemon detached with `-d` has none (see + /// detach.rs). Its stop path is `nightcrow stop`. pub(super) struct Watch(Receiver); impl Watch { diff --git a/src/plugin/guard.rs b/src/plugin/guard.rs index 8af45347..acf23bd6 100644 --- a/src/plugin/guard.rs +++ b/src/plugin/guard.rs @@ -114,7 +114,7 @@ impl Guard { /// Decide one command. Never panics. /// /// `facts` is what the caller knows about the pane `cmd`'s token resolves - /// to, or `None` if it resolves to nothing. `allowed_resume_flags` is the + /// to, or `None` if it resolves to nothing; `allowed_resume_flags` is the /// plugin's configured list. pub fn judge( &mut self, @@ -239,12 +239,11 @@ impl Guard { return Err(Refused::PaneStillRunning { pane }); } if facts.launch_command.is_none() { - // A bare shell. Putting a process back here would start the shell - // again, not whatever the person ran inside it, and the resume - // arguments would have nothing to attach to — so the pane's only - // recovery is the one typed into it while it is still alive. Checked - // before `resume_command_line`, which also refuses this, so the log - // says the pane was never relaunchable rather than blaming the args. + // A bare shell: the resume arguments would have nothing to attach + // to, so the pane's only recovery is one typed into it while + // alive. Checked before `resume_command_line` (which also refuses + // this) so the log says the pane was never relaunchable rather + // than blaming the args. return Err(Refused::NoLaunchCommand { pane }); } let command_line = resume_command_line( diff --git a/src/plugin/guard_budget.rs b/src/plugin/guard_budget.rs index 4d1cdafd..68fdb69b 100644 --- a/src/plugin/guard_budget.rs +++ b/src/plugin/guard_budget.rs @@ -101,10 +101,9 @@ impl Budgets { /// /// Only approvals spend, deliberately: the budget bounds what a plugin /// *does* to a pane, and a refused command did nothing. Charging refusals - /// would let noise — a stale generation the plugin could not have known - /// about, a flag config forbids — eat the budget a legitimate action needs, - /// losing the pane's one real attempt to a race. Spam is bounded elsewhere - /// and more cheaply: the outbound queue drops and every refusal is logged. + /// would let noise eat the allowance a legitimate action needs. Spam is + /// bounded elsewhere and more cheaply: the outbound queue drops and every + /// refusal is logged. pub(super) fn spend( &mut self, token: &PaneToken, diff --git a/src/plugin/guard_watch.rs b/src/plugin/guard_watch.rs index 83f3666c..6052eebd 100644 --- a/src/plugin/guard_watch.rs +++ b/src/plugin/guard_watch.rs @@ -1,30 +1,26 @@ //! Rule 12: when a plugin may be given a pane nobody handed it. //! -//! Every other rule in this layer starts from a pane the operator already -//! assigned. This one is the single place an assignment can be *created* at -//! runtime, so it is kept apart from the rest and reads as one list of -//! conditions rather than as a branch inside a larger judgement. +//! Every other rule starts from a pane the operator already assigned; this is +//! the single place an assignment can be *created* at runtime, so it is kept +//! apart and reads as one list of conditions. //! -//! What makes it safe is where the token came from. A pane token is random, is -//! minted per slot, and is put only into that pane's child environment, so a -//! process able to quote one is a process running inside that pane. The pane's -//! own occupant asking for a watcher is a different thing from a plugin -//! enumerating the session, and only the first is allowed here — nothing in this -//! file looks at a list of panes. -//! -//! Still not authority by itself: the operator's config switch has to be on, and -//! a pane already spoken for is not taken away from the plugin that has it. - +//! What makes it safe is where the token came from: a pane token is random, +//! minted per slot, and put only into that pane's child environment, so a +//! process able to quote one is running inside that pane. The pane's own +//! occupant asking for a watcher is allowed here; a plugin enumerating the +//! session is not — nothing in this file looks at a list of panes. Still not +//! authority by itself: the operator's config switch must be on, and a pane +//! already spoken for is not taken away. use super::guard::{Approved, PaneFacts}; use super::guard_refusal::Refused; use crate::backend::PaneToken; /// Decide one [`PluginCommand::WatchPane`](super::protocol::PluginCommand). /// -/// Takes no clock and charges no budget. Being given a pane is not something -/// done *to* the pane — it changes who is told about it, and every act that -/// follows is charged when it is asked for. Charging here would spend the very -/// allowance the recovery this unlocks is about to need. +/// Takes no clock and charges no budget: being given a pane changes who is +/// told about it, not the pane itself — every act that follows is charged +/// when it is asked for. Charging here would spend the very allowance the +/// recovery this unlocks is about to need. pub(super) fn judge_watch( token: &PaneToken, facts: Option<&PaneFacts>, diff --git a/src/plugin/host.rs b/src/plugin/host.rs index 0c0ad843..b1e6279e 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -87,10 +87,9 @@ impl PluginHost { /// Launch `cfg.command` and start pumping. /// /// Resolution order for the program: a `cfg.command` containing a path - /// separator is taken as a path and used as given; otherwise `plugin_dir` is - /// searched first, so an installed plugin wins over a same-named binary on - /// the user's `PATH`, and only if it is not there is the bare name handed to - /// the OS to resolve against `PATH`. + /// separator is used as given; otherwise `plugin_dir` is searched first + /// (an installed plugin wins over a same-named `PATH` binary), and only + /// then is the bare name handed to the OS. /// /// No pane token is passed in the environment. A plugin learns which panes /// exist only from the events it is sent, which is what keeps a plugin from @@ -117,9 +116,9 @@ impl PluginHost { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // After `cfg.env`, so this is not something a config can point at - // another hub's socket: which hub a plugin belongs to is the host's to - // say. See `PLUGIN_RUNTIME_DIR_ENV`. + // After `cfg.env`: which hub a plugin belongs to is the host's to say, + // not something a config can point at another hub's socket. See + // `PLUGIN_RUNTIME_DIR_ENV`. if let Some(dir) = runtime_dir { command.env(crate::backend::identity::PLUGIN_RUNTIME_DIR_ENV, dir); } @@ -285,8 +284,8 @@ fn resolve_program(command: &str, plugin_dir: Option<&Path>) -> PathBuf { if candidate.is_file() { return candidate; } - // On Windows, an installed plugin is stored as `name.exe` but - // configured as `name`. Try the extension before falling back to PATH. + // On Windows an installed plugin is stored as `name.exe` but + // configured as `name`; try the extension before PATH. #[cfg(windows)] { let exe = dir.join(format!("{command}.exe")); diff --git a/src/plugin/host_pump.rs b/src/plugin/host_pump.rs index e26033e3..e7574b6b 100644 --- a/src/plugin/host_pump.rs +++ b/src/plugin/host_pump.rs @@ -129,11 +129,11 @@ fn pull_line(reader: &mut impl BufRead) -> std::io::Result { /// Consume bytes up to and including the next newline, keeping at most /// [`MAX_LINE_BYTES`] of them in `buf` (the newline is never kept). /// -/// Returns how many bytes the line actually spanned and whether a newline ended -/// it. The cap is applied while reading rather than left to -/// [`decode_command`]'s own length check: by the time that runs the host has -/// already allocated whatever the plugin chose to send, which is the thing worth -/// preventing. +/// Returns how many bytes the line actually spanned and whether a newline +/// ended it. The cap is applied while reading rather than left to +/// [`decode_command`]'s length check: by the time that runs the host has +/// already allocated whatever the plugin chose to send, which is the thing +/// worth preventing. fn read_capped(reader: &mut impl BufRead, buf: &mut Vec) -> std::io::Result<(usize, bool)> { let mut seen = 0; loop { diff --git a/src/runtime/emulator/boundary.rs b/src/runtime/emulator/boundary.rs index df823867..89ed7d8d 100644 --- a/src/runtime/emulator/boundary.rs +++ b/src/runtime/emulator/boundary.rs @@ -1,28 +1,19 @@ //! Whether a pane's byte stream stands at a sequence boundary. //! -//! A snapshot is spliced *into* the recorded stream on replay: everything up to -//! its anchor, then the snapshot, then everything after (see -//! `session::terminal::hub_replay`). PTY reads land at arbitrary byte offsets, -//! so a chunk can end in the middle of an escape sequence or a multi-byte -//! character — anchoring there hands a reattaching client the sequence's tail -//! as ordinary input (`ESC [ 2` before the seam, a literal `J` printed onto the -//! fresh screen after it). The emulator's own parser knows it is mid-sequence, -//! but does not say so; this mirrors just enough of its state machine to answer -//! "is a sequence in flight", so the anchor can wait for a chunk that ends -//! clean. +//! A snapshot is spliced *into* the recorded stream on replay (see +//! `session::terminal::hub_replay`), and PTY reads land at arbitrary byte +//! offsets — anchoring inside an escape sequence or multi-byte character hands +//! a reattaching client the sequence's tail as ordinary input. The emulator's +//! own parser knows it is mid-sequence but does not say so, so this mirrors +//! just enough of its state machine to answer "is a sequence in flight". //! -//! Mirrors the parser's *abort* semantics as well as its progress — `CAN`, -//! `SUB` and a fresh `ESC` cancel whatever was open — so this cannot drift into -//! claiming a sequence that the real parser has already abandoned. -//! -//! Where the mirror and the parser disagree, they disagree in the safe -//! direction only: this may stay "open" after the parser has moved on (the raw -//! `0x9c` ST that ends a DCS is not followed, and neither are the DCS -//! sub-states it would need), which merely defers a snapshot. It never reports -//! a boundary the parser would not also be at. The cost of deferring is the -//! caller's to bound — see the desperation rule in -//! `session::terminal::hub_run` — because a cap *here* would be a lie told to -//! every caller at once. +//! The mirror tracks the parser's *abort* semantics (`CAN`, `SUB`, a fresh +//! `ESC`) as well as its progress, so it cannot drift into claiming a sequence +//! the real parser has abandoned. Where they disagree, they disagree safely: +//! this may stay "open" after the parser moved on, which only defers a +//! snapshot — it never reports a boundary the parser would not also be at. +//! The cost of deferring is the caller's to bound (see `session::terminal::hub_run`); +//! a cap *here* would be a lie told to every caller at once. #[derive(Clone, Copy, PartialEq, Eq)] enum State { @@ -99,7 +90,6 @@ impl StreamBoundary { 0x18 | 0x1a => Ground, 0x1b => Escape, 0x30..=0x7e => Ground, - // C0 controls execute without closing the sequence. _ => Escape, }, EscapeIntermediate => match byte { @@ -111,7 +101,6 @@ impl StreamBoundary { Csi => match byte { 0x40..=0x7e | 0x18 | 0x1a => Ground, 0x1b => Escape, - // Parameters, intermediates, embedded C0, and DEL. _ => Csi, }, Osc => match byte { diff --git a/src/runtime/emulator/mod.rs b/src/runtime/emulator/mod.rs index e63b63f1..38ed14c0 100644 --- a/src/runtime/emulator/mod.rs +++ b/src/runtime/emulator/mod.rs @@ -107,8 +107,8 @@ impl PaneEmulator { } } - /// Feed raw PTY output through the emulator, updating the screen state. - /// Returns the side effects (title change, terminal query responses). + /// Feed raw PTY output through the emulator; returns the side effects for + /// the caller to act on. pub fn process(&mut self, bytes: &[u8]) -> EmulatorEvents { self.boundary.feed(bytes); self.processor.advance(&mut self.term, bytes); @@ -125,8 +125,8 @@ impl PaneEmulator { } /// Resize the emulated screen, reflowing wrapped lines. Safe for any - /// size change, including one that cuts a wide character at the new - /// last column (the vt100 panic this module exists to avoid). + /// size change, including one that cuts a wide character at the new last + /// column — the vt100 panic this module exists to avoid. pub fn resize(&mut self, rows: u16, cols: u16) { self.term.resize(term_size(rows, cols)); } @@ -157,27 +157,21 @@ impl PaneEmulator { ScreenView { term: &self.term } } - /// The bytes that reproduce this screen on another terminal. - /// - /// What a client attaching to a pane is given in place of the recorded - /// bytes that cannot rebuild its screen: all of them for a program drawing - /// on the alternate screen, the evicted front of the ring for one on the - /// normal screen. See [`snapshot`] for what a snapshot does and does not - /// carry. + /// The bytes that reproduce this screen on another terminal — what a + /// client attaching to a pane is given in place of the recorded bytes, + /// which cannot rebuild the screen (see [`snapshot`] for what a snapshot + /// does and does not carry). pub fn screen_snapshot(&self) -> Vec { snapshot::screen_snapshot(&self.term) } /// Whether everything processed so far has reached the screen and ends with /// every escape sequence and multi-byte character closed. A screen snapshot - /// may only be anchored at such a point: it is spliced into the recorded - /// stream on replay, and a seam inside a sequence hands a reattaching - /// client the sequence's tail as ordinary input (see [`boundary`]). - /// - /// "Reached the screen" is [`screen_current`](Self::screen_current); the - /// closed-sequences half is the [`boundary`] tracker. They are separate - /// questions because a caller forcing a snapshot over a torn seam may still - /// never take one of a grid with bytes missing. + /// may only be anchored at such a point: a seam inside a sequence hands a + /// reattaching client the sequence's tail as ordinary input (see + /// [`boundary`]). The two halves answer separate questions — a caller + /// forcing a snapshot over a torn seam may still never take one of a grid + /// with bytes missing. pub fn at_boundary(&self) -> bool { self.screen_current() && self.boundary.at_boundary() } @@ -185,20 +179,17 @@ impl PaneEmulator { /// Whether the grid holds everything processed. False while a synchronized /// update (DEC 2026) is open: the processor buffers its bytes without /// applying them, so a snapshot taken then is missing bytes the record - /// would count as covered. An update ends with `ESU`, at the processor's - /// own buffer cap, or — for one the program never closed — when its owner - /// ticks [`settle_sync`](Self::settle_sync). + /// would count as covered. pub fn screen_current(&self) -> bool { self.processor.sync_bytes_count() == 0 } /// Which input, if any, a scroll request for this pane must be turned - /// into. Mouse reporting wins over `alternateScroll` because a program - /// that asked for wheel events wants them even on the alternate screen. - /// + /// into. Mouse reporting wins over `alternateScroll` (a program that asked + /// for wheel events wants them even on the alternate screen), but /// `MOUSE_MODE` alone is not enough: without `SGR_MOUSE` the program - /// expects the legacy X10 encoding, which cannot address columns past - /// 223. Such a pane falls back to `Scrollback`. + /// expects legacy X10 encoding, which cannot address columns past 223 — + /// such a pane falls back to `Scrollback`. pub fn scroll_sink(&self) -> ScrollSink { let mode = self.term.mode(); if mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) { @@ -211,8 +202,8 @@ impl PaneEmulator { } /// Whether the program asked for mouse button reports in SGR form — - /// the gate for forwarding clicks. A click has no scrollback fallback, - /// it is either claimed by the program or dropped. + /// the gate for forwarding clicks, which have no scrollback fallback: + /// a click is either claimed by the program or dropped. pub fn wants_mouse_buttons(&self) -> bool { let mode = self.term.mode(); mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) @@ -245,9 +236,8 @@ impl PaneEmulator { } } -/// Clamp a requested pane size to alacritty's supported minimum grid. -/// A 1-column grid makes wide-character reflow loop forever on resize. -/// +/// Clamp a requested pane size to alacritty's supported minimum grid — +/// a 1-column grid makes wide-character reflow loop forever on resize. /// `TerminalState` applies the same clamp to the backend PTY size and its /// `last_content_size` bookkeeping, so the PTY, the emulator grid, and the /// recorded size can never diverge at degenerate layouts. diff --git a/src/runtime/emulator/modes.rs b/src/runtime/emulator/modes.rs index ee288a28..b6b96bf8 100644 --- a/src/runtime/emulator/modes.rs +++ b/src/runtime/emulator/modes.rs @@ -2,26 +2,23 @@ //! them. Split from `mod.rs` so the alacritty-facing wrapper and this plain //! description of a pane's state stay separately readable. -/// The terminal modes a program sets once, at startup, and never repeats. -/// -/// A client that attaches later cannot learn these from the output it is -/// replayed: the bytes that set them are long gone from the pane's history. -/// Carried as plain flags so a caller outside this module can hold and compare -/// them, and turned back into the sequences that reproduce them by -/// [`PaneModes::prelude`]. +/// The terminal modes a program sets once, at startup, and never repeats — +/// a later-attaching client cannot learn them from replayed output. Carried +/// as plain flags so a caller outside this module can hold and compare them, +/// and turned back into sequences by [`PaneModes::prelude`]. /// /// [`Default`] is a *freshly opened* terminal rather than all-false: `25` -/// (visible cursor), `7` (autowrap) and `1007` (alternate scroll) are on until a -/// program turns them off. Pinned to what the emulator actually starts with, so -/// an emulator upgrade that changes its initial mode set fails there rather than -/// silently mis-describing a pane that has printed nothing yet. +/// (visible cursor), `7` (autowrap) and `1007` (alternate scroll) are on until +/// a program turns them off. Pinned to what the emulator actually starts with, +/// so an emulator upgrade that changes its initial mode set fails there rather +/// than silently mis-describing a pane that has printed nothing yet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneModes { /// DECSET 1049: the program draws on the alternate screen (vim, htop, - /// Claude Code in fullscreen rendering). The one mode that decides whether a - /// pane's recorded history is worth replaying at all — an alternate-screen - /// program's transcript lives in its own memory, and what reached the pane - /// is incremental paint, not text. + /// Claude Code in fullscreen rendering). The one mode that decides whether + /// a pane's recorded history is worth replaying at all — an + /// alternate-screen program's transcript lives in its own memory, and what + /// reached the pane is incremental paint, not text. pub alt_screen: bool, /// DECSET 1: arrows send `ESC O A` rather than `ESC [ A`. pub app_cursor: bool, @@ -70,14 +67,13 @@ impl PaneModes { /// The sequences that put another terminal into this state. /// /// **Every** tracked mode is emitted, set or reset, rather than only those - /// differing from a fresh terminal: the receiver is xterm.js, whose defaults - /// are its own business and need not match this emulator's (`1007` already - /// differs). An absolute prelude cannot be wrong about them; a relative one - /// would silently leave a mode at whatever the other side happens to start - /// with. The cost is a hundred bytes once per pane per connection. - /// - /// `1049` leads: it switches buffers, and the rest must land in the buffer - /// the program is drawing on. + /// differing from a fresh terminal: the receiver is xterm.js, whose + /// defaults are its own business and need not match this emulator's. An + /// absolute prelude cannot be wrong about them; a relative one would + /// silently leave a mode at whatever the other side happens to start with. + /// The cost is a hundred bytes once per pane per connection. `1049` leads: + /// it switches buffers, and the rest must land in the buffer the program + /// is drawing on. pub fn prelude(&self) -> Vec { let modes = [ (1049, self.alt_screen), diff --git a/src/runtime/emulator/snapshot.rs b/src/runtime/emulator/snapshot.rs index 89143db3..f2402b6c 100644 --- a/src/runtime/emulator/snapshot.rs +++ b/src/runtime/emulator/snapshot.rs @@ -1,32 +1,27 @@ //! Turning an emulated screen back into the bytes that reproduce it. //! -//! A pane's byte ring is history, not a screen. For a program drawing on the -//! alternate screen the recorded bytes are cell updates against a screen the -//! reader does not have, so replaying them paints fragments — and a -//! normal-screen program that repaints in place hits the same wall from the -//! other side: its repaints rotate the byte-bounded ring until the bytes that -//! painted the rest of the screen are evicted. Either way the emulator the hub -//! already runs to follow the pane's modes is holding the screen those bytes -//! produced. This turns its grid back into the bytes that paint it. +//! A pane's byte ring is history, not a screen. For an alternate-screen +//! program the recorded bytes are cell updates against a screen the reader +//! does not have; a normal-screen program that repaints in place instead +//! rotates the byte-bounded ring until the bytes that painted the screen are +//! evicted. Either way the emulator already runs is holding the screen those +//! bytes produced, and this turns its grid back into bytes that paint it. //! -//! Written as an **absolute repaint**: the screen is cleared with default -//! attributes, every row is positioned by `CUP`, and each run of equal -//! attributes costs one `SGR` that begins with a reset. Nothing in the output -//! depends on where the receiving terminal's cursor was or which attributes it -//! had, so the same snapshot is correct for a client that has just opened a -//! blank terminal and for one being repainted. +//! Written as an **absolute repaint**: screen cleared, every row positioned by +//! `CUP`, each attribute run costing one reset-leading `SGR`. Nothing depends +//! on where the receiving terminal's cursor was or which attributes it had, so +//! the same snapshot is correct for a fresh terminal and for a repaint. //! -//! **What a snapshot does not carry.** Wrap bookkeeping: `WRAPLINE` on a row -//! that continued into the next, and `LEADING_WIDE_CHAR_SPACER` on the filler -//! left when a wide character did not fit the last column. Both describe how a -//! row came to look this way rather than how it looks, and an absolute repaint -//! places each row independently — so a row that wrapped arrives as two rows and -//! a later resize reflows it differently from the original. Nothing reads that -//! difference today: alternate-screen programs redraw on resize, and a -//! normal-screen pane's history is still replayed from its byte ring, which -//! keeps its wrapping intact — the snapshot stands in only for the screen -//! itself. Underline colour, hyperlinks (OSC 8) and the scrolling region -//! (DECSTBM) are not carried either. +//! **What a snapshot does not carry.** Wrap bookkeeping (`WRAPLINE`, +//! `LEADING_WIDE_CHAR_SPACER`): both describe how a row came to look this way +//! rather than how it looks, and an absolute repaint places each row +//! independently — so a wrapped row arrives as two rows and a later resize +//! reflows it differently. Nothing reads that difference today: +//! alternate-screen programs redraw on resize, and a normal-screen pane's +//! history is still replayed from its byte ring, which keeps its wrapping — +//! the snapshot stands in only for the screen itself. Underline colour, +//! hyperlinks (OSC 8) and the scrolling region (DECSTBM) are not carried +//! either. use super::EventProxy; use alacritty_terminal::grid::Dimensions; @@ -37,9 +32,9 @@ use alacritty_terminal::vte::ansi::{Color, NamedColor}; use std::fmt::Write as _; /// How a cell looks. The remaining flags describe the grid's own bookkeeping -/// (wide-char spacers, wrap continuation) rather than anything a terminal can be -/// told to enter, so they are masked out — and comparing what is left is what -/// lets a run of equal attributes cost one escape. +/// rather than anything a terminal can be told to enter, so they are masked +/// out — and comparing what is left is what lets a run of equal attributes +/// cost one escape. #[derive(PartialEq, Eq, Clone, Copy)] struct Pen { fg: Color, @@ -57,12 +52,12 @@ impl Pen { } } -/// Whether a cell is indistinguishable from one that was never written, so a run -/// of them at the end of a row can be erased instead of spelled out. +/// Whether a cell is indistinguishable from one that was never written, so a +/// run of them at the end of a row can be erased instead of spelled out. /// -/// Held to *every* attribute rather than just the background: a space carrying a -/// foreground colour looks the same but is not the same cell, and erasing it would -/// hand a client a screen that differs from the one it is replacing. +/// Held to *every* attribute, not just the background: a space carrying a +/// foreground colour looks the same but is not the same cell, and erasing it +/// would hand a client a screen that differs from the one it replaces. fn is_blank(cell: &Cell) -> bool { cell.c == ' ' && cell.zerowidth().is_none() && Pen::of(cell) == Pen::of(&Cell::default()) } @@ -81,27 +76,26 @@ fn rendered_flags() -> Flags { pub(super) fn screen_snapshot(term: &Term) -> Vec { let grid = term.grid(); let (rows, cols) = (grid.screen_lines(), grid.columns()); - // One escape and one glyph per cell is the floor; the slack covers each row's - // `CUP` and the attribute runs. Reserved up front because a snapshot of a large - // pane is taken on the worker's tick, where a dozen reallocations of a - // megabyte-long string is the whole cost. + // One escape and one glyph per cell is the floor; the slack covers each + // row's `CUP` and the attribute runs. Reserved up front because a + // snapshot of a large pane is taken on the worker's tick, where a dozen + // reallocations of a megabyte-long string is the whole cost. let mut out = String::with_capacity(rows * cols + rows * 16 + 32); out.push_str("\x1b[m\x1b[2J"); - // Carried across rows: `SGR` survives a `CUP`, so a run of equal attributes - // spanning a row boundary still costs one escape. + // Carried across rows: `SGR` survives a `CUP`, so a run of equal + // attributes spanning a row boundary still costs one escape. let mut pen: Option = None; for row in 0..rows { // Positioned rather than reached by a newline. Writing the last column - // of a row leaves the cursor pending-wrap, and this `CUP` is what - // cancels it — which is also why a full row of cells can never scroll - // the screen. Grid line 0 is the top of the live screen whatever the - // display offset is, so this does not depend on the emulator's scroll. + // of a row leaves the cursor pending-wrap, and this `CUP` cancels it — + // also why a full row of cells can never scroll the screen. Grid line + // 0 is the top of the live screen whatever the display offset is. let _ = write!(out, "\x1b[{};1H", row + 1); - // Everything past the last cell worth naming is erased rather than spelled - // out. On a screen that is mostly empty — which most screens are, and a - // large pane especially — this is the difference between a snapshot of a - // few kilobytes and one of several hundred. + // Everything past the last cell worth naming is erased rather than + // spelled out. Most screens are mostly empty, and this is the + // difference between a snapshot of a few kilobytes and several + // hundred. let last = (0..cols) .rev() .find(|&col| !is_blank(&grid[Point::new(Line(row as i32), Column(col))])); @@ -131,9 +125,9 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { out.extend(zerowidth); } } - // Only when the row was not written to its last column: there the cursor is - // left pending-wrap *on* that column, and erasing to the end of the line - // from there would wipe the cell just written. + // Only when the row was not written to its last column: there the + // cursor is left pending-wrap *on* that column, and erasing from there + // would wipe the cell just written. if last + 1 < cols { out.push_str(ERASE_TO_END_OF_ROW); pen = Some(Pen::of(&Cell::default())); @@ -153,17 +147,18 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { out.into_bytes() } -/// Erase the rest of the row to blank cells. The reset leads because `EL` erases -/// with the *current* background, and what it has to leave behind is the default -/// one — which is what makes the erased cells equal the cells they stand in for. +/// Erase the rest of the row to blank cells. The reset leads because `EL` +/// erases with the *current* background, and what it has to leave behind is +/// the default one — which is what makes the erased cells equal the cells +/// they stand in for. const ERASE_TO_END_OF_ROW: &str = "\x1b[m\x1b[K"; -/// One absolute `SGR`. Leads with `0` so the sequence states the whole pen rather -/// than a change from whatever the reader had. +/// One absolute `SGR`. Leads with `0` so the sequence states the whole pen +/// rather than a change from whatever the reader had. /// -/// Appended in place rather than returned: on a densely coloured screen this runs -/// once per cell, and building a string per call was measurably the cost of the -/// whole snapshot. +/// Appended in place rather than returned: on a densely coloured screen this +/// runs once per cell, and building a string per call was measurably the cost +/// of the whole snapshot. fn write_sgr(out: &mut String, pen: Pen) { out.push_str("\x1b[0"); for (flag, param) in [ @@ -189,8 +184,8 @@ fn write_sgr(out: &mut String, pen: Pen) { out.push('m'); } -/// The `SGR` parameter selecting `color`. The default is written as nothing at -/// all — every sequence starts from a reset, so it needs no saying. +/// The `SGR` parameter selecting `color`. The default is written as nothing — +/// every sequence starts from a reset, so it needs no saying. /// /// A named colour with no fixed palette slot (`Cursor`, `BrightForeground`, /// `DimForeground`) defers to the default for the same reason diff --git a/src/runtime/emulator/sync.rs b/src/runtime/emulator/sync.rs index edf64dfe..05211876 100644 --- a/src/runtime/emulator/sync.rs +++ b/src/runtime/emulator/sync.rs @@ -1,16 +1,11 @@ //! Ending a synchronized update (DEC 2026) the program never closed. //! -//! Between `BSU` and `ESU` the processor holds the update's bytes back from the -//! grid, and ends the update only on `ESU` or at its own 2 MiB buffer cap. A -//! program that dies mid-frame — a TUI killed on exit, or one re-execing itself -//! to update — sends neither, and the pane it leaves behind produces nothing -//! but a shell prompt afterwards, so the cap is never reached either: the grid -//! stops moving for good while the shell underneath still takes input. The -//! pane looks frozen and is not. -//! -//! vte's answer is the 150 ms timeout it arms on `BSU` and leaves for its -//! caller to honour (alacritty ticks it from its event loop). Every owner of a -//! `PaneEmulator` ticks it here. +//! Between `BSU` and `ESU` the processor holds the update's bytes back from +//! the grid and ends it only on `ESU` or at its own 2 MiB buffer cap — a +//! program killed mid-frame sends neither, so the pane looks frozen while the +//! shell underneath still takes input. vte arms the 150 ms timeout on `BSU` +//! and leaves it for its caller to honour; every owner of a `PaneEmulator` +//! ticks it here. use super::{EmulatorEvents, PaneEmulator}; use std::time::Instant; @@ -27,7 +22,8 @@ impl PaneEmulator { /// End an open synchronized update, applying to the grid the bytes it held /// back. Harmless when none is open, but callers gate on - /// [`sync_expired`](Self::sync_expired) so a live update is never cut short. + /// [`sync_expired`](Self::sync_expired) so a live update is never cut + /// short. pub fn settle_sync(&mut self) -> EmulatorEvents { self.processor.stop_sync(&mut self.term); self.take_events() diff --git a/src/runtime/emulator/view.rs b/src/runtime/emulator/view.rs index 5a582ee7..77facd0e 100644 --- a/src/runtime/emulator/view.rs +++ b/src/runtime/emulator/view.rs @@ -102,9 +102,9 @@ impl CellView<'_> { /// Map an emulator color to a ratatui color. Named standard/bright colors /// become the equivalent indexed color so the user's terminal palette -/// applies; default foreground/background become `Reset` for the same -/// reason. Dim named colors map to their base color — `CellView::dim` -/// carries the dim attribute separately. +/// applies; default foreground/background and colors with no fixed palette +/// slot become `Reset` for the same reason. Dim named colors map to their +/// base color — `CellView::dim` carries the dim attribute separately. pub(super) fn to_ratatui_color(color: Color) -> ratatui::style::Color { use ratatui::style::Color as C; match color { diff --git a/src/runtime/snapshot.rs b/src/runtime/snapshot.rs index b85fb17e..1cb4ea36 100644 --- a/src/runtime/snapshot.rs +++ b/src/runtime/snapshot.rs @@ -13,21 +13,19 @@ mod worker; use worker::Worker; /// Owns the receiver and wake channel for the background snapshot thread. -/// Dropping the struct signals the worker to exit and joins it, so a repo switch -/// cannot leave the old-repo worker holding a `git2::Repository` after the new -/// channel is in place. +/// Dropping the struct signals the worker to exit and joins it, so a repo +/// switch cannot leave the old-repo worker holding a `git2::Repository` after +/// the new channel is in place. pub struct SnapshotChannel { rx: Receiver, - /// Cleared to stop reading the tree without stopping the worker. - /// - /// A `git status` is not free and one runs per channel. A caller that knows - /// nobody is reading turns it off rather than paying for snapshots that go - /// straight in the bin. The filesystem watch goes with it. + /// Cleared to stop reading the tree without stopping the worker: a + /// `git status` is not free and one runs per channel, so a caller that + /// knows nobody is reading turns it off. The filesystem watch goes with it. awake: Arc, /// Whether the worker is being told about *every* place a change can come /// from, rather than looking on a timer. False while asleep, on a tree the - /// watcher could not install on, and — until the first read answers where the - /// git directory is — on a checkout that keeps it outside the work tree. + /// watcher could not install on, and — until the first read answers where + /// the git directory is — on a checkout that keeps it outside the work tree. /// /// Nothing in production reads this — a failed watch is reported where it /// happens, and the reader behaves correctly either way. It exists so the @@ -36,10 +34,9 @@ pub struct SnapshotChannel { #[cfg(test)] watching: Arc, /// Wakes the worker: filesystem events, resumption, and the stop on drop. - /// One channel for all three, so an idle repository costs no wake-ups beyond - /// the interval that guards against missed events. - /// - /// Held in an `Option` so `Drop` can release it before joining the worker. + /// One channel for all three, so an idle repository costs no wake-ups + /// beyond the interval that guards against missed events. Held in an + /// `Option` so `Drop` can release it before joining the worker. wake: Option>, // None in test fixtures that construct an inert channel via // `from_endpoints` (no real worker to join). @@ -51,19 +48,17 @@ pub struct SnapshotChannel { /// poll cost and never more. const MIN_READ_INTERVAL: Duration = Duration::from_millis(1000); -/// Longest gap between two reads while awake and watching. -/// -/// A watcher can miss an event, or install on part of a tree and fail on the -/// rest, and "stale until the user happens to change something else" is not a -/// state to leave a file list in. With no watcher at all this is not used: the -/// reader falls back to [`MIN_READ_INTERVAL`]. +/// Longest gap between two reads while awake and watching — a watcher can miss +/// an event, and "stale until the user happens to change something else" is +/// not a state to leave a file list in. With no watcher at all this is not +/// used: the reader falls back to [`MIN_READ_INTERVAL`]. const IDLE_READ_INTERVAL: Duration = Duration::from_secs(10); /// Reopen the cached `git2::Repository` handle every N reads so we observe /// out-of-band repo changes (e.g. `git gc`, packfile rewrites, worktree moves) /// that the cached handle would otherwise serve stale. Counted in reads rather -/// than in seconds now that reads follow changes — a repository nobody touches -/// is not read, and does not need reopening either. +/// than seconds now that reads follow changes — a repository nobody touches is +/// not read, and does not need reopening either. const REOPEN_REPO_EVERY_READS: u32 = 30; impl SnapshotChannel { @@ -74,11 +69,10 @@ impl SnapshotChannel { /// Start without reading, for an owner that knows nobody is looking yet. /// - /// Separate from `spawn` followed by `set_awake(false)`, which is a race the - /// worker can win: it reads before that clears, which walks a tree nobody - /// asked about and leaves the reading queued to be published after a later, - /// newer one. The daemon opens every repository in a session and the browser - /// subscribes to one of them, so this is the ordinary case. + /// Separate from `spawn` followed by `set_awake(false)`, which is a race + /// the worker can win: it reads before that clears, which walks a tree + /// nobody asked about and leaves the reading queued to be published after + /// a later, newer one. pub fn spawn_asleep(repo_path: &str) -> Self { Self::start(repo_path, false) } @@ -109,12 +103,10 @@ impl SnapshotChannel { } } - /// A handle for turning the reading on and off from another thread. - /// - /// Separate from the channel because the channel owns a receiver and cannot - /// be shared, while whoever decides that nobody is reading — a server - /// counting its subscribers — is on a different thread from the one draining - /// it. + /// A handle for turning the reading on and off from another thread — + /// separate from the channel because the channel owns a receiver and + /// cannot be shared, while whoever decides nobody is reading (a server + /// counting subscribers) is on a different thread from the one draining it. pub fn watch(&self) -> SnapshotWatch { SnapshotWatch { awake: Arc::clone(&self.awake), @@ -140,9 +132,9 @@ impl SnapshotChannel { self.rx.try_recv() } - /// Build a `SnapshotChannel` from an externally provided receiver. Lets - /// tests construct an inert channel (no worker thread, no watcher) so they - /// can inject snapshots directly instead of booting the background reader. + /// Build a `SnapshotChannel` from an externally provided receiver, so + /// tests can construct an inert channel (no worker thread, no watcher) + /// and inject snapshots directly. #[cfg(test)] pub(crate) fn from_endpoints(rx: Receiver) -> Self { Self { @@ -166,8 +158,8 @@ impl SnapshotWatch { pub fn set_awake(&self, awake: bool) { self.awake.store(awake, Ordering::Release); // Woken rather than left to the interval: resuming means a client is - // waiting to see this repository, and it must not sit behind a timer that - // exists for missed events. + // waiting to see this repository, and it must not sit behind a timer + // that exists for missed events. if let Some(wake) = &self.wake { let _ = wake.send(Wake::Changed(Vec::new())); } @@ -176,14 +168,11 @@ impl SnapshotWatch { impl Drop for SnapshotChannel { fn drop(&mut self) { - // Release the wake sender first: the worker's `recv_timeout` observes the - // stop immediately rather than sitting out the idle interval. + // Release the wake sender first: the worker's `recv_timeout` observes + // the stop immediately rather than sitting out the idle interval. if let Some(wake) = self.wake.take() { let _ = wake.send(Wake::Stop); } - // Wait for the worker to finish its current `load_snapshot` so a - // `change_repo` doesn't leave the old-repo worker running with a - // live `git2::Repository` after the new channel is installed. // Bounded join: a worker stuck inside libgit2 (corrupted packfile, // hung NFS) must not freeze app shutdown / repo switch. if let Some(h) = self.handle.take() { diff --git a/src/runtime/snapshot/worker.rs b/src/runtime/snapshot/worker.rs index 299b1525..a5e1773e 100644 --- a/src/runtime/snapshot/worker.rs +++ b/src/runtime/snapshot/worker.rs @@ -24,9 +24,8 @@ pub(super) struct Worker { /// The watches the worker holds, and what it has already tried to watch. /// /// What was tried is recorded rather than inferred from the handles: a refusal -/// leaves no handle, and re-deriving "not installed yet" from that would re-walk -/// the tree and log the same warning once a second. A failure is answered by -/// falling back to the interval and retried only when what is wanted changes. +/// leaves no handle, and re-deriving "not installed yet" from that would +/// re-walk the tree and log the same warning once a second. #[derive(Default)] struct Watches { tree: Option, @@ -217,15 +216,13 @@ impl Worker { self.deliver(msg) } - /// Hand a reading over, unless nobody is reading any more. `false` once the - /// receiver is gone. + /// Hand a reading over, unless nobody is reading any more. `false` once + /// the receiver is gone. /// /// Checked again here rather than only before the walk, which on a large /// tree takes long enough for the last client to leave. A reading nobody - /// waited for is worse than wasted: it sits in the channel until whoever - /// owns the receiver next drains it, and that is after the next client has - /// taken a fresher reading for itself and shown it. The older one then lands - /// on top. + /// waited for lands in the channel after the next client's fresher + /// reading — and then on top of it. fn deliver(&self, msg: SnapshotMsg) -> bool { if !self.awake.load(Ordering::Acquire) { return true; diff --git a/src/runtime/snapshot_watch.rs b/src/runtime/snapshot_watch.rs index 026f196e..da69e020 100644 --- a/src/runtime/snapshot_watch.rs +++ b/src/runtime/snapshot_watch.rs @@ -186,8 +186,7 @@ fn matters(repo: Option<&git2::Repository>, roots: &Roots, path: &Path) -> bool return true; }; // Build output is the loudest thing in a working tree and the one thing git - // has been told to disregard: a `cargo build` writes thousands of files that - // cannot appear in a status. Skipping them is what makes this worth having. + // has been told to disregard: skipping it is what makes this worth having. // // A tracked file inside an ignored directory (added with `-f`) is the case // this skips wrongly. The idle read is what still catches it. @@ -197,16 +196,13 @@ fn matters(repo: Option<&git2::Repository>, roots: &Roots, path: &Path) -> bool /// Whether a change at `inside` — a path relative to a git directory — could /// change what a status says. /// -/// **Top level only, on purpose.** A submodule keeps a git directory of its own -/// under `modules//`, and the same churn happens there, so extending the -/// rule to those is tempting. It cannot be done from the path: a submodule's -/// name is its path in the tree, slashes and all, so `modules/foo/objects/HEAD` -/// is the `HEAD` of a submodule at `foo/objects` and the objects directory of -/// one at `foo` — and there is no counting of components that tells them apart. -/// Guessing costs a real change dropped in one direction and nothing gained in -/// the other, while admitting them all costs at most one extra read per second -/// during a submodule fetch, which is what the reader cost before it watched -/// anything. +/// **Top level only, on purpose.** Extending the rule to submodules +/// (`modules//`) is tempting but cannot be done from the path: a +/// submodule's name is its path in the tree, so `modules/foo/objects/HEAD` is +/// ambiguous between the `HEAD` of a submodule at `foo/objects` and the objects +/// directory of one at `foo`. Guessing costs a real change dropped; admitting +/// them all costs at most one extra read per second during a submodule fetch, +/// which is what the reader cost before it watched anything. fn git_metadata_matters(inside: &Path) -> bool { // Objects and reflogs churn on every commit and every fetch, and neither // changes a status by itself — the index or ref update that comes with them diff --git a/src/runtime/terminal/attention.rs b/src/runtime/terminal/attention.rs index 56175bcc..a5e60904 100644 --- a/src/runtime/terminal/attention.rs +++ b/src/runtime/terminal/attention.rs @@ -62,7 +62,7 @@ impl TerminalState { .or_insert_with(|| TitleActivity::new(now)); } - pub(super) fn settle_title_attention(&mut self, now: Instant) { + pub(super) fn settle_title_attention(&mut self, now: Instant) -> bool { let mut attention = false; self.title_activity.retain(|_, activity| { let Some(settled) = activity.settled_attention(now) else { @@ -72,6 +72,7 @@ impl TerminalState { false }); self.unread_attention |= attention; + attention } pub(crate) fn raise_attention(&mut self) { @@ -85,8 +86,8 @@ impl TerminalState { pub fn acknowledge_attention(&mut self) { self.unread_attention = false; // Activity already visible on this screen must not settle into a new - // unread event after the user switches away. If it keeps running in - // the background, later title changes start a fresh observation. + // unread event after the user switches away; later title changes + // start a fresh observation. self.title_activity.clear(); } } diff --git a/src/runtime/terminal/escape.rs b/src/runtime/terminal/escape.rs index 7b330dc1..1c40d0d8 100644 --- a/src/runtime/terminal/escape.rs +++ b/src/runtime/terminal/escape.rs @@ -35,8 +35,7 @@ fn consume_escape_sequence(chars: &mut std::iter::Peekable>) } Some('(') | Some(')') | Some('*') | Some('+') | Some('-') | Some('.') | Some('/') | Some('#') => { - // Charset designators / DEC private 2-byte escapes: - // ESC . Skip both. + // Charset designators / DEC private 2-byte escapes: skip both bytes. chars.next(); chars.next(); } @@ -50,12 +49,10 @@ fn consume_escape_sequence(chars: &mut std::iter::Peekable>) } /// CSI: consume parameter/intermediate bytes (0x20–0x3f), stop at the final -/// byte (0x40–0x7e). Break early on a control char so content that follows a -/// malformed sequence isn't accidentally eaten — and leave that control byte -/// in the iterator: eating it here would silently drop a `\n` or `\r` that -/// the outer pass needs to flush the prompt buffer. DEL (0x7f) is treated -/// per ECMA-48 as a no-op inside the sequence: consumed but does not stand -/// in for a final byte. +/// byte (0x40–0x7e). Break early on a control char and leave it in the +/// iterator: eating it here would silently drop a `\n`/`\r` the outer pass +/// needs to flush the prompt buffer. DEL (0x7f) is treated per ECMA-48 as a +/// no-op inside the sequence. fn consume_csi(chars: &mut std::iter::Peekable>) { while let Some(&c) = chars.peek() { if c < '\x20' { @@ -85,10 +82,9 @@ fn consume_osc(chars: &mut std::iter::Peekable>) { } } -/// SS3: ESC O . Used by xterm-style application keypad for arrow/ -/// function keys. Consume the next char only when it looks like a valid SS3 -/// final byte (0x40–0x7e) — a malformed `ESC O ` sequence used to swallow -/// the following ordinary char. +/// SS3: ESC O . Consume the next char only when it looks like a valid +/// SS3 final byte (0x40–0x7e) — a malformed `ESC O ` sequence used to +/// swallow the following ordinary char. fn consume_ss3(chars: &mut std::iter::Peekable>) { if let Some(&next) = chars.peek() && ('\x40'..='\x7e').contains(&next) diff --git a/src/runtime/terminal/input.rs b/src/runtime/terminal/input.rs index 269480e9..3a0a06e8 100644 --- a/src/runtime/terminal/input.rs +++ b/src/runtime/terminal/input.rs @@ -36,16 +36,15 @@ impl TerminalState { } } // 0x7f (DEL, sent by Backspace) and 0x08 (BS, sent by Ctrl+H) - // both remove the previous typed char. Without this branch the + // both remove the previous typed char; without this branch the // prompt log would accumulate typos the user already corrected. '\x7f' | '\x08' => { buf.pop(); } _ => { - // Cap to bound memory under degenerate "no-newline" producers - // (progress bars piped through cat, paste of a multi-MB - // string, etc.). Dropping further chars before the next flush - // is preferable to letting the buffer grow without limit. + // Cap to bound memory under degenerate "no-newline" + // producers (progress bars piped through cat, pastes of + // multi-MB strings); dropping chars beats unbounded growth. if buf.len() < PROMPT_BUFFER_MAX_BYTES { buf.push(ch); } diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs index d38f112c..3abcc4f6 100644 --- a/src/runtime/terminal/lifecycle.rs +++ b/src/runtime/terminal/lifecycle.rs @@ -6,17 +6,32 @@ impl TerminalState { /// Drain pending backend events into pane emulators and pane metadata. /// Returns the pane ids the backend signalled as exited so the caller /// can run cross-cutting cleanup (focus redirect, fullscreen reset). + #[cfg(test)] pub fn poll(&mut self) -> Vec { self.poll_at(std::time::Instant::now()) } + #[cfg(test)] pub(crate) fn poll_at(&mut self, now: std::time::Instant) -> Vec { + self.poll_at_with_activity(now).0 + } + + /// Drain terminal events and report whether anything can affect the next + /// rendered frame. The ordinary `poll` API remains exit-only for callers + /// that need just lifecycle cleanup; the TUI also needs output, title, + /// resize, and delayed synchronized-update activity. + pub(crate) fn poll_with_activity(&mut self) -> (Vec, bool) { + self.poll_at_with_activity(std::time::Instant::now()) + } + + pub(crate) fn poll_at_with_activity(&mut self, now: std::time::Instant) -> (Vec, bool) { let mut exited = Vec::new(); let events: Vec = self .backend .as_mut() .map(|b| b.drain_events()) .unwrap_or_default(); + let had_events = !events.is_empty(); for event in events { match event { @@ -38,17 +53,7 @@ impl TerminalState { // this client asked for — or any it asked for. The emulator has // to wrap where the child does, so it follows. BackendEvent::Resized { pane, rows, cols } => { - let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); - if let Some(emulator) = self.emulators.get_mut(&pane) { - emulator.resize(rows, cols); - } - // Only when this client is not the one sizing. For the owner - // this map is "what I last asked for", and overwriting it - // with a clamped answer would make the next frame ask again, - // every frame. - if !self.owns_size { - self.last_content_size.insert(pane, (rows, cols)); - } + self.confirm_resize(pane, rows, cols); } // Only for a pane this client holds, like `Exited`: a marker // for a pane that is not on any of this client's tabs would @@ -72,6 +77,11 @@ impl TerminalState { // forget what was applied and let the next frame fit them. if owned && !self.owns_size { self.last_content_size.clear(); + } else if !owned && self.owns_size { + self.last_content_size = self.confirmed_content_size.clone(); + } + if owned != self.owns_size { + self.pending_content_size.clear(); } self.owns_size = owned; } @@ -100,9 +110,9 @@ impl TerminalState { } // After the drain, so an update this tick's own output closed is never // cut short by the clock. - self.settle_sync_updates(now); - self.settle_title_attention(now); - exited + let settled_sync = self.settle_sync_updates(now); + let settled_title = self.settle_title_attention(now); + (exited, had_events || settled_sync || settled_title) } /// Allocate a new bare interactive-shell pane. @@ -113,7 +123,7 @@ impl TerminalState { /// Allocate a new backend pane and matching emulator. `command`, when /// present, is run in the pane's shell immediately; `label` sets the /// initial tab title (a program that emits OSC 0/2 can still override it - /// later). Both default sensibly when `None`. + /// later). pub fn create_pane_with( &mut self, command: Option<&str>, @@ -174,8 +184,8 @@ impl TerminalState { /// Take in a pane the backend reports. /// /// `requested` says whether this client asked: one it did takes the focus, - /// and one another client opened lands in the list without moving anybody's - /// cursor. + /// and one another client opened lands in the list without moving + /// anybody's cursor. fn adopt_pane( &mut self, id: PaneId, @@ -191,6 +201,7 @@ impl TerminalState { self.emulators .insert(id, PaneEmulator::new(rows, cols, SCROLLBACK_LINES)); self.last_content_size.insert(id, (rows, cols)); + self.confirmed_content_size.insert(id, (rows, cols)); // The session's name first: a configured startup terminal is called the // same thing in every client, and this one did not ask for it and has no // title queued for it. Then this client's own queued title, then the @@ -222,46 +233,11 @@ impl TerminalState { } self.scroll.remove(&id); self.last_content_size.remove(&id); + self.confirmed_content_size.remove(&id); + self.pending_content_size.remove(&id); self.title_activity.remove(&id); } - /// Resize each listed pane's backend PTY and emulator to its own - /// (rows, cols), skipping a pane whose size didn't change. `layouts` - /// carries one entry per currently *visible* pane — panes scrolled out of - /// the split-view window are omitted and keep their `last_content_size` - /// until they become visible again. - /// - /// A client that does not own the sizing changes nothing here: the panes are - /// at the owner's size and its own emulators are already following - /// [`BackendEvent::Resized`](crate::backend::BackendEvent::Resized). Its - /// layout still records what it would have asked for, which is the size a - /// pane it opens is born at. - pub fn resize_visible_panes(&mut self, layouts: &[(PaneId, u16, u16)]) { - let active_id = self.active_pane_id(); - for &(id, rows, cols) in layouts { - // Shared minimum-grid clamp: PTY, emulator, and the recorded - // size must all agree, or the skip-if-unchanged check and the - // inner program's wrap width drift apart at degenerate layouts. - let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); - if Some(id) == active_id { - self.size = (rows, cols); - } - if !self.owns_size { - continue; - } - if self.last_content_size.get(&id) == Some(&(rows, cols)) { - continue; - } - if let Some(backend) = &mut self.backend { - backend.resize(id, rows, cols); - } - if let Some(emulator) = self.emulators.get_mut(&id) { - emulator.resize(rows, cols); - } - self.last_content_size.insert(id, (rows, cols)); - } - } - /// Ask the session for the sizing. The answer arrives as /// [`BackendEvent::SizeOwnership`], which is what actually flips /// [`owns_size`](Self::owns_size) and re-fits the panes. diff --git a/src/runtime/terminal/mod.rs b/src/runtime/terminal/mod.rs index b87f9239..6beb9398 100644 --- a/src/runtime/terminal/mod.rs +++ b/src/runtime/terminal/mod.rs @@ -1,12 +1,14 @@ use crate::backend::{PaneId, TerminalBackend}; use crate::runtime::emulator::PaneEmulator; use std::collections::HashMap; +use std::time::Instant; mod attention; mod escape; mod input; mod lifecycle; mod recovery; +mod resize; mod scroll; mod session_panes; mod state; @@ -16,9 +18,8 @@ pub(crate) use escape::strip_escape_sequences; pub use recovery::PaneRecovery; /// Upper bound on a pane's in-flight prompt buffer before further chars are -/// dropped. Prevents unbounded growth when a program writes a stream of bytes -/// without ever sending `\r` / `\n` (progress bars, large pastes, `yes` piped -/// to cat). +/// dropped, so a program writing bytes without ever sending `\r`/`\n` +/// (progress bars, large pastes) cannot grow it without limit. const PROMPT_BUFFER_MAX_BYTES: usize = 4096; /// Scrollback line cap for every pane emulator. @@ -27,7 +28,7 @@ pub const SCROLLBACK_LINES: usize = 1000; /// Lines moved by a single line-scroll keypress (`Shift+Up`/`Shift+Down`). pub const SCROLL_LINE_STEP: usize = 3; -/// Lines one mouse wheel notch scrolls, by terminal convention. Used to +/// Lines one mouse wheel notch scrolls, by terminal convention — used to /// convert a line count into a notch count when a pane wants wheel events, /// and by the mouse handler as the line count of one captured wheel event. pub const WHEEL_LINES_PER_NOTCH: usize = 3; @@ -46,13 +47,9 @@ pub const MAX_VISIBLE_NORMAL: usize = 4; pub const MAX_VISIBLE_FULLSCREEN: usize = 8; /// Fullscreen state of the lower terminal panel. ` f` cycles through -/// `Off → Grid → Zoom → Off`. -/// - `Off`: normal split — top viewer above, terminal split-view below. -/// - `Grid`: terminal fills the body; up to `MAX_VISIBLE_FULLSCREEN` panes. -/// - `Zoom`: terminal fills the body showing only the active pane. -/// -/// `Grid` and `Zoom` are visually identical whenever `Grid` would show a -/// single pane, so the cycle skips `Zoom` in that case. +/// `Off → Grid → Zoom → Off`. `Grid` and `Zoom` are visually identical +/// whenever `Grid` would show a single pane, so the cycle skips `Zoom` in +/// that case (see [`TerminalState::zoom_distinct_from_grid`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TerminalFullscreen { #[default] @@ -69,12 +66,11 @@ impl TerminalFullscreen { } } -/// Compute the visible pane-index window `[start, start+len)` for a split -/// grid capped at `max_visible` panes. `prev_start` is the previous window's -/// start (0 for a fresh terminal); the window is nudged the minimum amount -/// needed to keep `active` inside it, rather than re-centering every call. -/// Shared by `TerminalState::sync_visible_window` and `ui::terminal_tab` so -/// both always agree on what's visible. +/// Compute the visible pane-index window for a split grid capped at +/// `max_visible` panes. The window is nudged the minimum amount needed to +/// keep `active` inside it, rather than re-centering every call. Shared by +/// `TerminalState::sync_visible_window` and `ui::terminal_tab` so both always +/// agree on what's visible. pub(crate) fn visible_range( prev_start: usize, active: usize, @@ -105,20 +101,23 @@ pub struct TerminalState { pub size: (u16, u16), pub scroll: HashMap, pub fullscreen: TerminalFullscreen, - /// Last (rows, cols) applied to each pane's backend + emulator via - /// `resize_visible_panes`. Panes scrolled out of the visible window keep - /// whatever size they had when they were last visible. + /// Desired (rows, cols) for each pane while this client owns sizing; the + /// confirmed session size while it observes another owner. Panes scrolled + /// out of the visible window keep their last value. pub last_content_size: HashMap, + /// Last size confirmed as applied by the backend. + pub(crate) confirmed_content_size: HashMap, + /// Resize requests awaiting confirmation or a retry deadline. + pub(crate) pending_content_size: HashMap, /// Whether this client's layout is what sets the pane sizes. /// /// True unless a shared session says otherwise: a PTY has one size, so one /// client decides it and the others render the grid they are given. pub owns_size: bool, - /// What each pane's plugin last reported about recovering it, for the panes - /// any has spoken about. Deliberately outlives a pane's process: the report - /// that matters most arrives while the pane is gone and its slot is held for - /// a relaunch. Cleared only by a `cancelled` report (see - /// [`recovery::RECOVERY_CANCELLED`]). + /// What each pane's plugin last reported about recovering it. Deliberately + /// outlives a pane's process: the report that matters most arrives while + /// the pane is gone and its slot is held for a relaunch. Cleared only by + /// a `cancelled` report (see [`recovery::RECOVERY_CANCELLED`]). pub(crate) recovery: HashMap, /// Index of the first pane in the visible split-view window. pub visible_start: usize, @@ -146,6 +145,8 @@ impl TerminalState { scroll: HashMap::new(), fullscreen: TerminalFullscreen::Off, last_content_size: HashMap::new(), + confirmed_content_size: HashMap::new(), + pending_content_size: HashMap::new(), owns_size: true, recovery: HashMap::new(), visible_start: 0, @@ -162,5 +163,11 @@ impl TerminalState { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct PendingPaneResize { + size: (u16, u16), + attempted_at: Instant, +} + #[cfg(test)] mod tests; diff --git a/src/runtime/terminal/recovery.rs b/src/runtime/terminal/recovery.rs index 355451a0..6da3b1a3 100644 --- a/src/runtime/terminal/recovery.rs +++ b/src/runtime/terminal/recovery.rs @@ -61,14 +61,12 @@ impl TerminalState { self.recovery.get(&pane) } - /// The one report a person is looking at, and the one the cancel key acts on. - /// - /// The focused pane's own report comes first. Failing that, a report for a - /// pane this client no longer lists — a pane whose process has ended while - /// its slot is held for a relaunch. That pane cannot be focused, and it is - /// exactly the one someone would want to release, so it must still be - /// reachable. Lowest id wins so the display and the key can never disagree - /// about which one that is. + /// The one report a person is looking at, and the one the cancel key acts + /// on: the focused pane's own report first, failing that a report for a + /// pane this client no longer lists (its process ended while its slot is + /// held for a relaunch). That pane cannot be focused, and it is exactly + /// the one someone would want to release. Lowest id wins so the display + /// and the key can never disagree about which one that is. pub fn recovery_focus(&self) -> Option<(PaneId, &PaneRecovery)> { if let Some(pane) = self.active_pane_id() && let Some(report) = self.recovery.get(&pane) @@ -89,10 +87,9 @@ impl TerminalState { } /// Ask the session to give up on the recovery a person is looking at. - /// /// Nothing is cleared here: the entry goes when the session broadcasts - /// `cancelled`, which is also what tells every other client. Assuming it - /// locally would hide a cancellation the session refused. + /// `cancelled` (which tells every other client too) — assuming it locally + /// would hide a cancellation the session refused. pub fn cancel_recovery(&mut self) { let Some((pane, _)) = self.recovery_focus() else { return; diff --git a/src/runtime/terminal/resize.rs b/src/runtime/terminal/resize.rs new file mode 100644 index 00000000..90fa98a0 --- /dev/null +++ b/src/runtime/terminal/resize.rs @@ -0,0 +1,88 @@ +use super::{PendingPaneResize, TerminalState}; +use crate::backend::{PaneId, ResizeOutcome}; +use std::time::{Duration, Instant}; + +const RESIZE_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +impl TerminalState { + /// Fit each visible pane to its rendered cells. Remote backends confirm the + /// applied size asynchronously, so desired, pending, and confirmed geometry + /// remain distinct until a `Resized` event arrives. + pub fn resize_visible_panes(&mut self, layouts: &[(PaneId, u16, u16)]) { + self.resize_visible_panes_at(layouts, Instant::now()); + } + + pub(crate) fn resize_visible_panes_at(&mut self, layouts: &[(PaneId, u16, u16)], now: Instant) { + let active_id = self.active_pane_id(); + for &(id, rows, cols) in layouts { + let size = crate::runtime::emulator::effective_size(rows, cols); + if Some(id) == active_id { + self.size = size; + } + if !self.owns_size { + continue; + } + self.last_content_size.insert(id, size); + if self.confirmed_content_size.get(&id) == Some(&size) { + self.pending_content_size.remove(&id); + continue; + } + let retry_due = self.pending_content_size.get(&id).is_none_or(|pending| { + pending.size != size + || now.saturating_duration_since(pending.attempted_at) >= RESIZE_RETRY_INTERVAL + }); + if retry_due { + self.request_resize(id, size, now); + } + } + } + + fn request_resize(&mut self, id: PaneId, size: (u16, u16), now: Instant) { + let outcome = self + .backend + .as_mut() + .map(|backend| backend.resize(id, size.0, size.1)) + .unwrap_or(Ok(ResizeOutcome::Applied)); + match outcome { + Ok(ResizeOutcome::Applied) => { + if let Some(emulator) = self.emulators.get_mut(&id) { + emulator.resize(size.0, size.1); + } + self.confirmed_content_size.insert(id, size); + self.pending_content_size.remove(&id); + } + Ok(ResizeOutcome::Pending) => { + self.note_resize_attempt(id, size, now); + } + Err(err) => { + tracing::warn!(%err, pane = id, rows = size.0, cols = size.1, "could not resize a terminal pane"); + self.note_resize_attempt(id, size, now); + } + } + } + + fn note_resize_attempt(&mut self, id: PaneId, size: (u16, u16), now: Instant) { + self.pending_content_size.insert( + id, + PendingPaneResize { + size, + attempted_at: now, + }, + ); + } + + pub(super) fn confirm_resize(&mut self, pane: PaneId, rows: u16, cols: u16) { + let Some(emulator) = self.emulators.get_mut(&pane) else { + return; + }; + let size = crate::runtime::emulator::effective_size(rows, cols); + emulator.resize(size.0, size.1); + self.confirmed_content_size.insert(pane, size); + // A matching ACK completes the request; an older ACK also clears it so + // the desired/confirmed mismatch is retried immediately next frame. + self.pending_content_size.remove(&pane); + if !self.owns_size { + self.last_content_size.insert(pane, size); + } + } +} diff --git a/src/runtime/terminal/scroll.rs b/src/runtime/terminal/scroll.rs index 300dc22f..8b05c878 100644 --- a/src/runtime/terminal/scroll.rs +++ b/src/runtime/terminal/scroll.rs @@ -17,11 +17,9 @@ impl TerminalState { /// Scroll pane `id` by `lines`, delivering the request wherever that /// pane's program expects it (see `ScrollSink`). `pointer` is the 1-based /// pane-local cell of a captured mouse wheel event, when there is one. - /// /// Only the `Scrollback` sink moves the emulator's view; the other two /// synthesize input, because a program that owns its viewport keeps its - /// transcript out of the emulator's grid entirely and scrolling the grid - /// would reveal nothing. + /// transcript out of the emulator's grid entirely. pub fn scroll_pane(&mut self, id: PaneId, up: bool, lines: usize, pointer: Option<(u16, u16)>) { if lines == 0 { return; @@ -36,10 +34,9 @@ impl TerminalState { ScrollSink::MouseWheel => { // A TUI may pick which of its regions to scroll from the // report's coordinates, so a captured wheel event passes the - // real pointer cell through. Keyboard scrolls have no - // pointer and report the pane's centre instead — the only - // cell guaranteed to be inside the transcript rather than on - // a border or input box. + // real pointer cell through. Keyboard scrolls have no pointer + // and report the pane's centre instead — the only cell + // guaranteed to be inside the transcript. let (col, row) = match pointer { Some(cell) => cell, None => { @@ -73,8 +70,7 @@ impl TerminalState { /// Forward a horizontal wheel notch to pane `id` as an SGR report at the /// pointer cell. Horizontal scrolling has no scrollback or arrow-key /// analog, so there is no sink dispatch: a pane whose program asked for - /// wheel reports receives the notch, every other pane silently drops it - /// (the same rule as `click_pane`). + /// wheel reports receives the notch, every other pane silently drops it. pub fn wheel_horizontal_pane(&mut self, id: PaneId, left: bool, col: u16, row: u16) { let Some(emulator) = self.emulators.get(&id) else { return; @@ -88,11 +84,10 @@ impl TerminalState { /// Forward a mouse button press or release to pane `id`, translated to an /// SGR report at 1-based pane-local `col`/`row`. Only a pane whose program - /// asked for SGR mouse reports receives anything: a click has no - /// scrollback fallback, so an unclaimed click is dropped — the same - /// silence rule that keeps scroll bytes out of plain shells. Returns - /// whether the report was sent, so the caller can pair a forwarded press - /// with its eventual release. + /// asked for SGR mouse reports receives anything: an unclaimed click is + /// dropped — the same silence rule that keeps scroll bytes out of plain + /// shells. Returns whether the report was sent, so the caller can pair a + /// forwarded press with its eventual release. pub fn click_pane( &mut self, id: PaneId, @@ -112,7 +107,7 @@ impl TerminalState { true } - /// Write straight to a pane's PTY. Bypasses `send_input` on purpose: + /// Write straight to a pane's PTY, bypassing `send_input` on purpose: /// input we synthesized on the user's behalf must not clear their scroll /// position or land in the prompt log, for the same reason the emulator's /// query replies in `poll` bypass it. diff --git a/src/runtime/terminal/session_panes.rs b/src/runtime/terminal/session_panes.rs index 6b5cd9fb..b4544cc9 100644 --- a/src/runtime/terminal/session_panes.rs +++ b/src/runtime/terminal/session_panes.rs @@ -12,11 +12,9 @@ impl TerminalState { /// Ask for the active pane to be closed. Reports whether there was one to /// ask about; an empty list is a benign no-op. /// - /// A request, like a create. The pane goes when the session says it did - /// ([`BackendEvent::Exited`]), which is also how a pane someone else closed - /// arrives. Removing it here instead would show it gone while its process - /// kept running — and a close the session never carried out (a full command - /// queue drops one) would leave this client unable to see that pane again. + /// A request, like a create: the pane goes when the session says it did, + /// which is also how a pane someone else closed arrives. Removing it here + /// instead would show it gone while its process kept running. pub fn close_active(&mut self) -> bool { let Some(info) = self.panes.get(self.active) else { return false; @@ -60,15 +58,15 @@ impl TerminalState { /// Put the panes in the order the session gives. /// - /// Reconciled rather than applied blindly, because the client and the session - /// can disagree for a beat: an id this client has not adopted yet is skipped, - /// and a pane the order omits keeps its place at the end. Focus follows the - /// *pane* it was on rather than the slot — the point of a swap is to move a - /// pane while still looking at it. Per-pane state (emulators, scroll, sizes, - /// prompt buffers) is keyed by id, so none of it moves. + /// Reconciled rather than applied blindly, because the client and the + /// session can disagree for a beat: an id this client has not adopted yet + /// is skipped, and a pane the order omits keeps its place at the end. + /// Focus follows the *pane* it was on rather than the slot — the point of + /// a swap is to move a pane while still looking at it. /// - /// Test-only for a locally-backed state, which has no session to be told by; - /// [`swap_active_with`](Self::swap_active_with) is what asks in production. + /// Test-only for a locally-backed state, which has no session to be told + /// by; [`swap_active_with`](Self::swap_active_with) is what asks in + /// production. pub(crate) fn apply_order(&mut self, order: &[PaneId]) { let active_id = self.active_pane_id(); let mut taken: Vec = Vec::with_capacity(self.panes.len()); diff --git a/src/runtime/terminal/state.rs b/src/runtime/terminal/state.rs index 63245045..1d04f1a9 100644 --- a/src/runtime/terminal/state.rs +++ b/src/runtime/terminal/state.rs @@ -18,9 +18,8 @@ impl TerminalState { } } - /// Whether `Zoom` would render differently from `Grid` — i.e. whether - /// `Grid` would show more than one pane. When false the two are - /// indistinguishable, so the fullscreen cycle skips `Zoom` and a pane + /// Whether `Zoom` would render differently from `Grid`. When false the two + /// are indistinguishable, so the fullscreen cycle skips `Zoom` and a pane /// close normalizes `Zoom` back to `Grid`. Guards against both a lone pane /// and a `max_visible_fullscreen` of 1, so no site has to assume the cap /// is ≥ 2. @@ -38,8 +37,8 @@ impl TerminalState { } /// Row count used for terminal-scroll paging: the active pane's own - /// content height when known, otherwise the default pane size. Callers - /// used to read `size` directly, which no longer tracks per-pane height. + /// content height when known, otherwise the default pane size (callers + /// used to read `size` directly, which no longer tracks per-pane height). pub fn active_pane_rows(&self) -> usize { self.active_pane_id() .map(|id| self.pane_size(id).0 as usize) diff --git a/src/runtime/terminal/sync.rs b/src/runtime/terminal/sync.rs index f7c78a79..f77671a6 100644 --- a/src/runtime/terminal/sync.rs +++ b/src/runtime/terminal/sync.rs @@ -15,7 +15,6 @@ use super::TerminalState; impl TerminalState { /// Route what an emulator produced while processing: a window title to the /// pane's tab, and terminal query replies back to the program that asked. - /// /// Replies bypass [`send_input`](Self::send_input) on purpose: an /// emulator-generated answer must not clear the user's scroll position or /// land in the prompt log. @@ -51,13 +50,14 @@ impl TerminalState { /// End every synchronized update that has outlived its timeout as of /// `now`, applying the bytes it was holding back. - pub(super) fn settle_sync_updates(&mut self, now: Instant) { + pub(super) fn settle_sync_updates(&mut self, now: Instant) -> bool { let expired: Vec = self .emulators .iter() .filter(|(_, emulator)| emulator.sync_expired(now)) .map(|(id, _)| *id) .collect(); + let had_expired = !expired.is_empty(); for pane in expired { let Some(emulator) = self.emulators.get_mut(&pane) else { continue; @@ -65,5 +65,6 @@ impl TerminalState { let events = emulator.settle_sync(); self.apply_emulator_events(pane, events, now); } + had_expired } } diff --git a/src/runtime/terminal/tests/activity.rs b/src/runtime/terminal/tests/activity.rs new file mode 100644 index 00000000..df8e2585 --- /dev/null +++ b/src/runtime/terminal/tests/activity.rs @@ -0,0 +1,34 @@ +use super::common::state_with_event_queue; +use crate::backend::BackendEvent; +use std::time::Instant; + +#[test] +fn terminal_poll_activity_is_false_when_no_backend_event_arrives() { + let (mut state, _events) = state_with_event_queue(); + + let (_, changed) = state.poll_at_with_activity(Instant::now()); + + assert!(!changed, "an idle terminal must not request a frame"); +} + +#[test] +fn terminal_poll_activity_reports_output_and_resize_events() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Output { + pane, + data: b"output".to_vec(), + }); + let (_, output_changed) = state.poll_at_with_activity(Instant::now()); + assert!(output_changed, "PTY output must request a frame"); + + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 24, + cols: 80, + }); + let (_, resize_changed) = state.poll_at_with_activity(Instant::now()); + assert!(resize_changed, "a confirmed resize must request a frame"); +} diff --git a/src/runtime/terminal/tests/mod.rs b/src/runtime/terminal/tests/mod.rs index 9de1038a..e844d4e7 100644 --- a/src/runtime/terminal/tests/mod.rs +++ b/src/runtime/terminal/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod activity; mod common; mod lifecycle_tests; mod poll_tests; diff --git a/src/runtime/terminal/tests/size_owner_tests.rs b/src/runtime/terminal/tests/size_owner_tests.rs index 0551d187..e8981a34 100644 --- a/src/runtime/terminal/tests/size_owner_tests.rs +++ b/src/runtime/terminal/tests/size_owner_tests.rs @@ -4,7 +4,20 @@ //! render the grid they are given. These are the client's half of that. use super::common::state_with_event_queue; -use crate::backend::BackendEvent; +use crate::backend::{BackendEvent, ResizeOutcome}; +use crate::runtime::terminal::TerminalState; +use std::time::{Duration, Instant}; + +type EventQueue = std::rc::Rc>>; +type ResizeCalls = std::rc::Rc>>; + +fn state_with_pending_resize() -> (TerminalState, EventQueue, ResizeCalls) { + let backend = crate::test_util::FakeBackend::with_resize_outcome(ResizeOutcome::Pending); + let events = backend.pending_events.clone(); + let resized = backend.resized.clone(); + let state = TerminalState::new(Some(Box::new(backend)), false); + (state, events, resized) +} #[test] fn a_client_owns_its_sizes_until_a_session_says_otherwise() { @@ -64,10 +77,9 @@ fn a_spectator_follows_the_size_the_session_reports() { } #[test] -fn the_owner_follows_a_size_it_did_not_ask_for_without_asking_again() { - // Its request can come back clamped. The emulator has to follow the PTY, - // but the record of what was *asked for* must not, or every frame would - // re-send a size the hub will clamp the same way — forever. +fn the_owner_keeps_its_desired_size_separate_from_the_confirmed_size() { + // The emulator follows what the PTY reports, while the desired layout stays + // intact so an older acknowledgement cannot overwrite the final width. let (mut state, events) = state_with_event_queue(); state.create_pane_now().unwrap(); let pane = state.panes[0].id; @@ -115,3 +127,97 @@ fn taking_the_sizing_back_re_applies_this_client_layout() { state.resize_visible_panes(&[(pane, 24, 80)]); assert_eq!(state.last_content_size.get(&pane), Some(&(24, 80))); } + +#[test] +fn an_unconfirmed_resize_is_retried_after_the_deadline() { + let (mut state, _events, resized) = state_with_pending_resize(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(99)); + assert_eq!(resized.borrow().len(), 1, "pending resize is not flooded"); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(100)); + assert_eq!(resized.borrow().len(), 2, "an unanswered resize retries"); +} + +#[test] +fn a_late_ack_cannot_strand_the_emulator_at_an_old_width() { + let (mut state, events, resized) = state_with_pending_resize(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_millis(1)); + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 30, + cols: 100, + }); + state.poll_at(start + Duration::from_millis(2)); + assert_eq!(state.screen_for_pane(pane).unwrap().size(), (30, 100)); + + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_millis(3)); + assert_eq!( + resized.borrow().last().copied(), + Some((pane, 40, 120)), + "desired and confirmed differ, so the latest width is requested again" + ); + assert_eq!(resized.borrow().len(), 3); + + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 40, + cols: 120, + }); + state.poll_at(start + Duration::from_millis(4)); + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_secs(1)); + assert_eq!(state.screen_for_pane(pane).unwrap().size(), (40, 120)); + assert_eq!(resized.borrow().len(), 3, "confirmed size stays settled"); +} + +#[test] +fn a_failed_resize_is_not_recorded_as_applied() { + let mut backend = crate::test_util::FakeBackend::default(); + backend.resize_error = true; + let resized = backend.resized.clone(); + let mut state = TerminalState::new(Some(Box::new(backend)), false); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let original = state.screen_for_pane(pane).unwrap().size(); + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + + assert_eq!(state.screen_for_pane(pane).unwrap().size(), original); + assert_ne!(state.confirmed_content_size.get(&pane), Some(&(30, 100))); + assert!(state.pending_content_size.contains_key(&pane)); + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(100)); + assert_eq!( + resized.borrow().len(), + 2, + "a failed resize remains retryable" + ); +} + +#[test] +fn an_ack_for_a_removed_pane_does_not_recreate_its_size_state() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + state.remove_pane_state(pane); + state.panes.clear(); + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 30, + cols: 100, + }); + + state.poll(); + + assert!(!state.confirmed_content_size.contains_key(&pane)); + assert!(!state.pending_content_size.contains_key(&pane)); +} diff --git a/src/runtime/tree_watch.rs b/src/runtime/tree_watch.rs index 6d3879b6..6facd8bb 100644 --- a/src/runtime/tree_watch.rs +++ b/src/runtime/tree_watch.rs @@ -15,9 +15,8 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::time::Duration; /// Coalescing window for filesystem events. Long enough to batch the burst a -/// single `git`/editor/agent operation produces into one refresh, short enough -/// to feel live. Sits between nvim-tree (50 ms) and gitui (2 s); broot uses -/// 500 ms. +/// single `git`/editor/agent operation produces into one refresh, short +/// enough to feel live. const DEBOUNCE: Duration = Duration::from_millis(300); /// Owns the debounced filesystem watcher and the set of currently watched @@ -30,7 +29,6 @@ const DEBOUNCE: Duration = Duration::from_millis(300); /// In tests (and when the watcher fails to start) `debouncer` is `None`: the /// receiver still exists so `App` polling is uniform, and watch/unwatch calls /// become no-ops. -/// What changed since the last poll. #[derive(Debug, Default, PartialEq, Eq)] pub struct TreeChanges { /// Repo-relative directories whose contents changed. A file event is diff --git a/src/session/catalog/catalog_ids.rs b/src/session/catalog/catalog_ids.rs index 4c17e2b8..d2f53b03 100644 --- a/src/session/catalog/catalog_ids.rs +++ b/src/session/catalog/catalog_ids.rs @@ -45,6 +45,11 @@ pub(super) struct IdAssigner { by_path: HashMap, } +pub(super) struct Member { + pub(super) id: String, + pub(super) path: String, +} + impl IdAssigner { pub(super) fn id_for(&mut self, path: &str) -> String { if let Some(existing) = self.by_path.get(path) { diff --git a/src/session/catalog/catalog_runtime.rs b/src/session/catalog/catalog_runtime.rs new file mode 100644 index 00000000..76e099a2 --- /dev/null +++ b/src/session/catalog/catalog_runtime.rs @@ -0,0 +1,111 @@ +//! Live workers corresponding to the catalog's pure membership. + +use super::catalog_ids::{Member, RepoEntry}; +use super::{display_path, empty_status_payload, repo_name}; +use crate::session::StatusEncoder; +use crate::session::runtime::RepoRuntime; +use crate::session::terminal::TerminalHub; +use std::sync::Arc; + +pub(super) struct CatalogRuntime { + entries: Vec>, + startup_commands: Vec, + cli_startup: Vec, + plugins: Vec, + shell: crate::config::ShellConfig, + ownership: Arc, + status_encoder: StatusEncoder, +} + +impl Default for CatalogRuntime { + fn default() -> Self { + Self { + entries: Vec::new(), + startup_commands: Vec::new(), + cli_startup: Vec::new(), + plugins: Vec::new(), + shell: crate::config::ShellConfig::default(), + ownership: Arc::new(crate::session::size_owner::SizeOwnership::default()), + status_encoder: empty_status_payload, + } + } +} + +impl CatalogRuntime { + pub(super) fn configured( + startup_commands: Vec, + plugins: Vec, + cli_startup: Vec, + shell: crate::config::ShellConfig, + status_encoder: StatusEncoder, + ) -> Self { + Self { + startup_commands, + plugins, + cli_startup, + shell, + status_encoder, + ..Self::default() + } + } + + pub(super) fn reconcile(&mut self, members: Vec) -> Vec> { + let previous = std::mem::take(&mut self.entries); + let mut next = Vec::with_capacity(members.len()); + for member in members { + if let Some(existing) = previous.iter().find(|entry| entry.path == member.path) { + next.push(Arc::clone(existing)); + continue; + } + next.push(Arc::new(RepoEntry { + name: repo_name(&member.path), + display_path: display_path(&member.path), + runtime: RepoRuntime::spawn(&member.path, self.status_encoder), + terminals: TerminalHub::spawn( + &member.path, + self.startup_commands.clone(), + self.plugins.clone(), + self.shell.clone(), + Arc::clone(&self.ownership), + ), + id: member.id, + path: member.path, + })); + } + let retired = previous + .into_iter() + .filter(|old| !next.iter().any(|new| Arc::ptr_eq(new, old))) + .collect(); + self.entries = next; + retired + } + + pub(super) fn replace_config( + &mut self, + file_startup: &[crate::config::StartupCommand], + plugins: Vec, + ) -> anyhow::Result>> { + let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?; + self.startup_commands = merged; + self.plugins = plugins; + Ok(self.entries.clone()) + } + + pub(super) fn entries(&self) -> &[Arc] { + &self.entries + } + + pub(super) fn take_entries(&mut self) -> Vec> { + std::mem::take(&mut self.entries) + } + + #[cfg(test)] + pub(super) fn startup_commands(&self) -> Vec { + self.startup_commands.clone() + } + + #[cfg(test)] + pub(super) fn plugins(&self) -> Vec { + self.plugins.clone() + } +} diff --git a/src/session/catalog/catalog_tests/config_tables.rs b/src/session/catalog/catalog_tests/config_tables.rs index 50f5f0ec..ed13e7c1 100644 --- a/src/session/catalog/catalog_tests/config_tables.rs +++ b/src/session/catalog/catalog_tests/config_tables.rs @@ -106,3 +106,56 @@ fn a_repo_opened_after_a_swap_gets_the_new_startup_list() { catalog.shutdown(); drop((dir_a, dir_b)); } + +#[test] +fn concurrent_open_and_config_swap_cannot_miss_each_other() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Arc::new(Catalog::with_startup_and_plugins( + vec![startup("old")], + Vec::new(), + )); + catalog.set_paths(std::slice::from_ref(&a)); + let barrier = Arc::new(std::sync::Barrier::new(3)); + + let opening = { + let catalog = Arc::clone(&catalog); + let barrier = Arc::clone(&barrier); + let a = a.clone(); + let b = b.clone(); + std::thread::spawn(move || { + barrier.wait(); + catalog.set_paths(&[a, b]); + }) + }; + let swapping = { + let catalog = Arc::clone(&catalog); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + catalog + .set_config_tables(&[startup("new")], Vec::new()) + .expect("the merge fits the cap") + }) + }; + barrier.wait(); + opening.join().unwrap(); + let told = swapping.join().unwrap(); + + let b = crate::git::resolve_repo_path(std::path::Path::new(&b)) + .to_string_lossy() + .into_owned(); + let opened = catalog + .entries() + .into_iter() + .find(|entry| entry.path == b) + .expect("the concurrent open committed"); + let was_in_swap = told.iter().any(|entry| Arc::ptr_eq(entry, &opened)); + let spawned_from_new_table = opened.terminals.startup_commands() == [startup("new")]; + assert!( + was_in_swap || spawned_from_new_table, + "an opened repository must be told by the swap or spawn from its tables" + ); + catalog.shutdown(); + drop((dir_a, dir_b)); +} diff --git a/src/session/catalog/config_tables.rs b/src/session/catalog/config_tables.rs index e04d86e5..42c0dbb5 100644 --- a/src/session/catalog/config_tables.rs +++ b/src/session/catalog/config_tables.rs @@ -6,7 +6,7 @@ //! the hubs spawned afterwards. use super::Catalog; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; impl Catalog { /// Like [`Catalog::new`], with startup terminals and their plugin table. @@ -30,9 +30,13 @@ impl Catalog { cli_startup: Vec, ) -> Self { Self { - startup_commands: Mutex::new(startup_commands), - plugins: Mutex::new(plugins), - cli_startup, + runtime: std::sync::Mutex::new(super::CatalogRuntime::configured( + startup_commands, + plugins, + cli_startup, + crate::config::ShellConfig::default(), + super::empty_status_payload, + )), ..Self::default() } } @@ -46,11 +50,13 @@ impl Catalog { status_encoder: crate::session::StatusEncoder, ) -> Self { Self { - startup_commands: Mutex::new(startup_commands), - plugins: Mutex::new(plugins), - cli_startup, - shell, - status_encoder: Some(status_encoder), + runtime: std::sync::Mutex::new(super::CatalogRuntime::configured( + startup_commands, + plugins, + cli_startup, + shell, + status_encoder, + )), ..Self::default() } } @@ -65,8 +71,9 @@ impl Catalog { /// already running is the caller's job (see [`crate::session::reload`]). /// The entries to tell are returned rather than fetched afterwards. /// - /// Taken under the mutation lock, the same one every rebuild holds. Without - /// it a repository opened in the same beat could fall between the two halves: + /// Taken under the facade transaction, the same one every membership + /// commit holds. Without it a repository opened in the same beat could + /// fall between the two halves: /// its hub reads the old tables while the swap is still to come, and the /// swap's snapshot is taken while its entry is still to be installed. pub fn set_config_tables( @@ -74,35 +81,33 @@ impl Catalog { file_startup: &[crate::config::StartupCommand], plugins: Vec, ) -> anyhow::Result>> { - // Merged before any lock is taken, so a refusal leaves both tables - // exactly as they were. - let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?; - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - *self - .startup_commands + let _transaction = self + .transaction .lock() - .expect("catalog startup poisoned") = merged; - *self.plugins.lock().expect("catalog plugins poisoned") = plugins; - Ok(self.entries()) + .expect("catalog transaction poisoned"); + self.runtime + .lock() + .expect("catalog runtime poisoned") + .replace_config(file_startup, plugins) } /// The `[[plugin]]` table as it stands, for the caller that has to tell the /// running hubs about it. #[cfg(test)] pub fn plugins(&self) -> Vec { - self.plugins + self.runtime .lock() - .expect("catalog plugins poisoned") - .clone() + .expect("catalog runtime poisoned") + .plugins() } /// The merged startup list as it stands — configured panes then `--exec` /// ones. What the next hub will be given. #[cfg(test)] pub fn startup_commands(&self) -> Vec { - self.startup_commands + self.runtime .lock() - .expect("catalog startup poisoned") - .clone() + .expect("catalog runtime poisoned") + .startup_commands() } } diff --git a/src/session/catalog/membership.rs b/src/session/catalog/membership.rs new file mode 100644 index 00000000..68b679c3 --- /dev/null +++ b/src/session/catalog/membership.rs @@ -0,0 +1,138 @@ +//! Pure bookkeeping for which repository paths belong to the session. + +use super::catalog_ids::{IdAssigner, Member}; + +#[derive(Default)] +pub(super) struct CatalogMembership { + ids: IdAssigner, + base: Vec, + added: Vec, + hidden: Vec, + order: Vec, +} + +pub(super) enum AddMembership { + Present(String), + TooMany, +} + +impl CatalogMembership { + pub(super) fn set_paths(&mut self, paths: Vec) { + self.base = paths; + } + + pub(super) fn add_path(&mut self, path: String, max: usize) -> AddMembership { + if let Some(member) = self + .members() + .into_iter() + .find(|member| member.path == path) + { + return AddMembership::Present(member.id); + } + + let was_hidden = self.hidden.iter().any(|hidden| hidden == &path); + let candidate_len = self.union_paths_with_visible(&path, was_hidden).len(); + if candidate_len > max { + return AddMembership::TooMany; + } + + if was_hidden { + self.hidden.retain(|hidden| hidden != &path); + // A close forgets the old slot. A later base refresh may have put + // the hidden path back, but an explicit reopen still belongs last. + self.base.retain(|base| base != &path); + } + if !self.added.iter().any(|added| added == &path) { + self.added.push(path.clone()); + } + AddMembership::Present(self.ids.id_for(&path)) + } + + pub(super) fn remove_path(&mut self, path: &str) { + for list in [&mut self.added, &mut self.base, &mut self.order] { + list.retain(|entry| entry != path); + } + if !self.hidden.iter().any(|hidden| hidden == path) { + self.hidden.push(path.to_string()); + } + } + + pub(super) fn reorder(&mut self, desired: &[String]) { + let served = self.union_paths(); + let mut next = Vec::with_capacity(served.len()); + for path in desired { + if served.contains(path) && !next.contains(path) { + next.push(path.clone()); + } + } + for path in served { + if !next.contains(&path) { + next.push(path); + } + } + self.order = next; + } + + pub(super) fn members(&mut self) -> Vec { + self.union_paths() + .into_iter() + .map(|path| Member { + id: self.ids.id_for(&path), + path, + }) + .collect() + } + + fn union_paths_with_visible(&self, path: &str, was_hidden: bool) -> Vec { + let mut base = self.base.clone(); + let mut added = self.added.clone(); + let hidden: Vec<_> = self + .hidden + .iter() + .filter(|hidden| !was_hidden || hidden.as_str() != path) + .cloned() + .collect(); + if was_hidden { + base.retain(|base| base != path); + } + if !added.iter().any(|added| added == path) { + added.push(path.to_string()); + } + union_paths(&base, &added, &hidden, &self.order) + } + + fn union_paths(&self) -> Vec { + union_paths(&self.base, &self.added, &self.hidden, &self.order) + } +} + +fn union_paths( + base: &[String], + added: &[String], + hidden: &[String], + order: &[String], +) -> Vec { + let mut natural = Vec::with_capacity(base.len() + added.len()); + for path in base.iter().chain(added) { + if hidden.contains(path) || natural.contains(path) { + continue; + } + natural.push(path.clone()); + } + let mut result = Vec::with_capacity(natural.len()); + for path in order { + if natural.contains(path) && !result.contains(path) { + result.push(path.clone()); + } + } + for path in natural { + if !result.contains(&path) { + result.push(path); + } + } + result +} + +#[cfg(test)] +#[path = "membership_tests.rs"] +mod tests; diff --git a/src/session/catalog/membership_tests.rs b/src/session/catalog/membership_tests.rs new file mode 100644 index 00000000..fb160010 --- /dev/null +++ b/src/session/catalog/membership_tests.rs @@ -0,0 +1,92 @@ +use super::*; + +fn paths(membership: &mut CatalogMembership) -> Vec { + membership + .members() + .into_iter() + .map(|member| member.path) + .collect() +} + +fn id_of(membership: &mut CatalogMembership, path: &str) -> String { + membership + .members() + .into_iter() + .find(|member| member.path == path) + .expect("path is served") + .id +} + +#[test] +fn base_and_browser_paths_form_a_stable_deduplicated_union() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "a".into(), "b".into()]); + assert!(matches!( + membership.add_path("c".into(), 3), + AddMembership::Present(_) + )); + + membership.set_paths(vec!["b".into(), "a".into()]); + + assert_eq!(paths(&mut membership), ["b", "a", "c"]); +} + +#[test] +fn ids_survive_removal_reopen_and_reorder() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into()]); + let a_id = id_of(&mut membership, "a"); + let b_id = id_of(&mut membership, "b"); + + membership.reorder(&["b".into(), "a".into()]); + membership.remove_path("a"); + assert!(matches!( + membership.add_path("a".into(), 2), + AddMembership::Present(_) + )); + + assert_eq!(paths(&mut membership), ["b", "a"]); + assert_eq!(id_of(&mut membership, "a"), a_id); + assert_eq!(id_of(&mut membership, "b"), b_id); +} + +#[test] +fn hidden_paths_stay_closed_across_base_refresh_and_reopen_at_the_end() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + membership.remove_path("b"); + + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + assert_eq!(paths(&mut membership), ["a", "c"]); + assert!(matches!( + membership.add_path("b".into(), 3), + AddMembership::Present(_) + )); + assert_eq!(paths(&mut membership), ["a", "c", "b"]); +} + +#[test] +fn refused_reopen_does_not_clear_hidden_membership() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into()]); + membership.remove_path("a"); + membership.set_paths(vec!["a".into(), "b".into()]); + + assert!(matches!( + membership.add_path("a".into(), 1), + AddMembership::TooMany + )); + membership.set_paths(vec!["a".into(), "b".into()]); + + assert_eq!(paths(&mut membership), ["b"]); +} + +#[test] +fn reorder_ignores_unknown_and_duplicate_paths() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + + membership.reorder(&["c".into(), "unknown".into(), "c".into()]); + + assert_eq!(paths(&mut membership), ["c", "a", "b"]); +} diff --git a/src/session/catalog/mod.rs b/src/session/catalog/mod.rs index 01a0b128..c04b2c42 100644 --- a/src/session/catalog/mod.rs +++ b/src/session/catalog/mod.rs @@ -5,65 +5,48 @@ //! Ids are stable for the process lifetime, so opening or closing an unrelated //! tab does not renumber the others. //! -//! Replacement is atomic and does no blocking work under the lock: the new list -//! is built, swapped in, and only then are the dropped runtimes stopped — a -//! runtime shutdown joins a thread, and holding the catalog lock across that -//! would stall every in-flight request. +//! Replacement is atomic: membership is committed to the live runtime snapshot +//! under the facade transaction. Dropped runtimes are returned from that commit +//! and stopped only after every catalog lock is released — shutdown joins a +//! thread and must not stall in-flight reads or the next mutation. //! //! **Every path in here is the one `resolve_repo_path` produces**, normalised on -//! the way in rather than by each caller (see [`Catalog::normalized`]). Two -//! spellings of one worktree are two strings, and the whole catalog — the served -//! set, `hidden`, `order` — decides identity by comparing them, so a path that -//! arrived spelled differently opened a second tab on a repository already open. -//! Holding the invariant at the boundary is what keeps the next entry point from -//! having to remember. +//! the way in rather than by each caller. Two spellings of one worktree are two +//! strings, and the whole catalog decides identity by comparing them, so a path +//! that arrived spelled differently opened a second tab on a repository already +//! open. Holding the invariant at the boundary keeps every entry point from +//! having to remember it. -use crate::session::StatusEncoder; -use crate::session::runtime::RepoRuntime; -use crate::session::terminal::TerminalHub; use std::path::Path; use std::sync::{Arc, Mutex}; mod catalog_ids; +mod catalog_runtime; mod config_tables; +mod membership; mod ordering; -use catalog_ids::IdAssigner; pub use catalog_ids::{AddOutcome, RepoEntry, RepoInfo}; +use catalog_runtime::CatalogRuntime; +use membership::{AddMembership, CatalogMembership}; -#[derive(Default)] pub struct Catalog { - mutation: Mutex<()>, - entries: Mutex>>, - ids: Mutex, - /// Repositories supplied by the CLI (`serve --repo`) or pushed from the TUI - /// workspace. Replaced wholesale by [`Catalog::set_paths`]. - base: Mutex>, - /// Repositories opened from the browser. Kept across `base` updates. - added: Mutex>, - /// Repositories closed from the browser. Subtracted from the served set so - /// a `base` re-sync does not resurrect a closed repo. - hidden: Mutex>, - order: Mutex>, - /// Commands each repository's terminal hub runs as startup terminals on the - /// first client connect. Behind a lock because a config reload replaces it; - /// only hubs spawned *after* the reload see the new list. - startup_commands: Mutex>, - /// The `--exec` panes the daemon was started with, appended after the - /// configured ones. Not behind a lock: these came from the command line. - cli_startup: Vec, - /// The `[[plugin]]` table, handed to every hub the catalog spawns. Replaced - /// by a reload; the hubs already running are told as well, because a plugin - /// is a child process and restarting one costs the session nothing. - plugins: Mutex>, - /// The shell every terminal pane is spawned with. Fixed for the session's - /// life: a config reload does not replace the shell of a running hub. - shell: crate::config::ShellConfig, - /// Which screen this session's panes are fitted to, shared by every hub. - /// One value for the session rather than one per repository — see - /// [`crate::session::size_owner`]. - ownership: Arc, - /// Surface-owned status representation cached by each repository runtime. - status_encoder: Option, + /// Serializes membership-to-runtime commits and config swaps. The two + /// subobjects have independent locks so read-only runtime snapshots do not + /// need the membership bookkeeping, but a mutation always crosses them as + /// one facade transaction. + transaction: Mutex<()>, + membership: Mutex, + runtime: Mutex, +} + +impl Default for Catalog { + fn default() -> Self { + Self { + transaction: Mutex::new(()), + membership: Mutex::new(CatalogMembership::default()), + runtime: Mutex::new(CatalogRuntime::default()), + } + } } impl Catalog { @@ -75,10 +58,9 @@ impl Catalog { /// One worktree's single spelling: what `git` calls the working directory, /// or the canonical directory when there is no repository there. /// - /// Applied to everything entering the catalog. `add_path`'s caller resolves - /// too, and doing it twice costs one `discover` on a path a person just - /// asked for — cheap next to the alternative, which is this invariant - /// depending on every caller having remembered. + /// Applied to everything entering the catalog, even though `add_path`'s + /// caller resolves too — doing it twice costs one `discover`, cheap next + /// to this invariant depending on every caller having remembered. fn normalized(path: &str) -> String { crate::git::resolve_repo_path(Path::new(path)) .to_string_lossy() @@ -89,16 +71,8 @@ impl Catalog { /// open tabs. Browser-opened repositories ([`Catalog::add_path`]) survive /// this, so a workspace change does not close a tab a viewer opened. pub fn set_paths(&self, paths: &[String]) { - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - { - let mut base = self.base.lock().expect("catalog base poisoned"); - // The one entry point that took paths from outside untouched: a - // `--repo` argument and a workspace file hold whatever spelling was - // typed or last written, which is not necessarily what a client - // opening the same repository will send. - *base = paths.iter().map(|p| Self::normalized(p)).collect(); - } - self.rebuild(); + let paths = paths.iter().map(|path| Self::normalized(path)).collect(); + self.change_membership(|membership| membership.set_paths(paths)); } /// Add a repository opened from the browser, returning its identity. @@ -108,147 +82,97 @@ impl Catalog { /// served set is at `max`, so a client cannot spawn unbounded runtimes. pub fn add_path(&self, path: String, max: usize) -> AddOutcome { let path = Self::normalized(&path); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - // Opening a path clears any prior close, so a previously removed repo - // comes back rather than staying suppressed by `hidden`. - { - let mut hidden = self.hidden.lock().expect("catalog hidden poisoned"); - hidden.retain(|h| h != &path); - } - let union = self.union_paths(); - if !union.iter().any(|p| p == &path) { - if union.len() >= max { - return AddOutcome::TooMany; - } - { - let mut added = self.added.lock().expect("catalog added poisoned"); - if !added.iter().any(|p| p == &path) { - added.push(path.clone()); - } - } - } - self.rebuild(); - match self.info_for_path(&path) { - Some(info) => AddOutcome::Added(info), - // rebuild always creates the entry; this only trips if a concurrent - // set_paths raced it back out, which the caller can treat as full. - None => AddOutcome::TooMany, - } + let (outcome, retired) = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + let mut membership = self.membership.lock().expect("catalog membership poisoned"); + let id = match membership.add_path(path, max) { + AddMembership::Present(id) => id, + AddMembership::TooMany => return AddOutcome::TooMany, + }; + let members = membership.members(); + drop(membership); + let mut runtime = self.runtime.lock().expect("catalog runtime poisoned"); + let retired = runtime.reconcile(members); + let info = runtime + .entries() + .iter() + .find(|entry| entry.id == id) + .expect("accepted membership is committed to the runtime") + .info(); + (AddOutcome::Added(info), retired) + }; + stop_entries(retired); + outcome } /// Close a repository opened or shown in the browser. Dropped from every /// list that decides the served set and remembered in `hidden` so a `base` - /// re-sync will not bring it back; `rebuild` then stops its runtime and - /// terminals. + /// re-sync will not bring it back; the facade transaction then retires its + /// runtime and terminals. /// - /// A close forgets the slot the repository held, `base` and `order` included. - /// Leaving it in either meant [`Catalog::add_path`] found the path already in - /// `union_paths` and never appended it, so re-opening put the tab back in the - /// middle of the strip rather than at the end where it was just asked for. + /// A close forgets the slot the repository held, `base` and `order` + /// included. Leaving it in either meant [`Catalog::add_path`] found the + /// path already in `union_paths` and never appended it, so re-opening put + /// the tab back in the middle of the strip rather than at the end. pub fn remove_path(&self, path: &str) { let path = &Self::normalized(path); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - for list in [&self.added, &self.base, &self.order] { - list.lock() - .expect("catalog path list poisoned") - .retain(|p| p != path); - } - { - let mut hidden = self.hidden.lock().expect("catalog hidden poisoned"); - if !hidden.iter().any(|h| h == path) { - hidden.push(path.to_string()); - } - } - self.rebuild(); + self.change_membership(|membership| membership.remove_path(path)); } - fn info_for_path(&self, path: &str) -> Option { - self.entries - .lock() - .expect("catalog poisoned") - .iter() - .find(|e| e.path == path) - .map(|e| e.info()) + fn change_membership(&self, change: impl FnOnce(&mut CatalogMembership)) { + self.change_membership_if(|membership| { + change(membership); + true + }); } - /// Reconcile the live entries to `union_paths()`. A path already present - /// keeps its entry — and therefore its runtime and every SSE subscriber - /// attached to it. Only genuinely new paths start a runtime, and only - /// genuinely removed ones stop. - fn rebuild(&self) { - let deduped = self.union_paths(); - // Read once, before the entries lock: every hub this pass spawns is - // given the same tables, so a reload landing mid-rebuild cannot leave two - // repositories opened in the same beat configured differently. - let startup = self - .startup_commands - .lock() - .expect("catalog startup poisoned") - .clone(); - let plugins = self - .plugins - .lock() - .expect("catalog plugins poisoned") - .clone(); - - let assigned: Vec<(String, String)> = { - let mut ids = self.ids.lock().expect("catalog ids poisoned"); - deduped - .iter() - .map(|path| (ids.id_for(path), path.clone())) - .collect() - }; - - let retired = { - let mut entries = self.entries.lock().expect("catalog poisoned"); - let previous = std::mem::take(&mut *entries); - - let mut next = Vec::with_capacity(assigned.len()); - for (id, path) in assigned { - match previous.iter().find(|e| e.path == path) { - Some(existing) => next.push(Arc::clone(existing)), - None => next.push(Arc::new(RepoEntry { - name: repo_name(&path), - display_path: display_path(&path), - runtime: RepoRuntime::spawn( - &path, - self.status_encoder.unwrap_or(empty_status_payload), - ), - terminals: TerminalHub::spawn( - &path, - startup.clone(), - plugins.clone(), - self.shell.clone(), - Arc::clone(&self.ownership), - ), - id, - path, - })), - } + fn change_membership_if(&self, change: impl FnOnce(&mut CatalogMembership) -> bool) -> bool { + let (changed, retired) = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + let mut membership = self.membership.lock().expect("catalog membership poisoned"); + let changed = change(&mut membership); + if !changed { + return false; } - - let retired: Vec<_> = previous - .into_iter() - .filter(|old| !next.iter().any(|new| Arc::ptr_eq(new, old))) - .collect(); - *entries = next; - retired + let members = membership.members(); + drop(membership); + let retired = self + .runtime + .lock() + .expect("catalog runtime poisoned") + .reconcile(members); + (true, retired) }; - - // Outside the lock: stopping a runtime joins its thread. - for entry in retired { - entry.runtime.stop(); - entry.terminals.stop(); - } + stop_entries(retired); + changed } /// Stop every runtime. Called on server shutdown. pub fn shutdown(&self) { - let entries = std::mem::take(&mut *self.entries.lock().expect("catalog poisoned")); - for entry in entries { - entry.runtime.stop(); - entry.terminals.stop(); - } + let retired = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + self.runtime + .lock() + .expect("catalog runtime poisoned") + .take_entries() + }; + stop_entries(retired); + } +} + +fn stop_entries(entries: Vec>) { + for entry in entries { + entry.runtime.stop(); + entry.terminals.stop(); } } diff --git a/src/session/catalog/ordering.rs b/src/session/catalog/ordering.rs index fd063010..d8b9982a 100644 --- a/src/session/catalog/ordering.rs +++ b/src/session/catalog/ordering.rs @@ -1,57 +1,10 @@ use super::Catalog; impl Catalog { - pub(super) fn union_paths(&self) -> Vec { - let natural = { - let base = self.base.lock().expect("catalog base poisoned"); - let added = self.added.lock().expect("catalog added poisoned"); - let hidden = self.hidden.lock().expect("catalog hidden poisoned"); - let mut natural = Vec::with_capacity(base.len() + added.len()); - for path in base.iter().chain(added.iter()) { - if hidden.iter().any(|h| h == path) || natural.contains(path) { - continue; - } - natural.push(path.clone()); - } - natural - }; - - let order = self.order.lock().expect("catalog order poisoned"); - if order.is_empty() { - return natural; - } - let mut result = Vec::with_capacity(natural.len()); - for path in order.iter() { - if natural.iter().any(|served| served == path) && !result.contains(path) { - result.push(path.clone()); - } - } - for path in natural { - if !result.contains(&path) { - result.push(path); - } - } - result - } - pub fn reorder(&self, desired: &[String]) { // Normalised like every other path entering the catalog, so an order // given in a different spelling still names the repositories it means. let desired: Vec = desired.iter().map(|p| Self::normalized(p)).collect(); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - let served = self.union_paths(); - let mut next = Vec::with_capacity(served.len()); - for path in &desired { - if served.iter().any(|served| served == path) && !next.contains(path) { - next.push(path.clone()); - } - } - for path in &served { - if !next.contains(path) { - next.push(path.clone()); - } - } - *self.order.lock().expect("catalog order poisoned") = next; - self.rebuild(); + self.change_membership(|membership| membership.reorder(&desired)); } } diff --git a/src/session/catalog/views.rs b/src/session/catalog/views.rs index 48ddf38d..9f975438 100644 --- a/src/session/catalog/views.rs +++ b/src/session/catalog/views.rs @@ -20,9 +20,10 @@ pub struct ServedView { impl Catalog { pub fn get(&self, id: &str) -> Option> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .find(|e| e.id == id) .map(Arc::clone) @@ -32,28 +33,29 @@ impl Catalog { /// rather than a client-facing projection. /// /// A snapshot: the `Arc`s are cloned out and the lock released. + #[cfg(test)] pub fn entries(&self) -> Vec> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(Arc::clone) .collect() } /// The served list and, from that same snapshot, the id standing for - /// `remembered`. - /// - /// One lock for both, because a client renders them together: a repository - /// opened between two separate reads would yield an active id missing from - /// the list beside it. + /// `remembered` — one lock for both, because a client renders them together + /// and a repository opened between two separate reads would yield an active + /// id missing from the list beside it. pub fn list_with_active( &self, remembered: Option<&str>, maximized: &[crate::session::prefs::RepoMaximized], views: &[crate::session::prefs::RepoView], ) -> ServedView { - let entries = self.entries.lock().expect("catalog poisoned"); + let runtime = self.runtime.lock().expect("catalog runtime poisoned"); + let entries = runtime.entries(); let list = entries.iter().map(|e| e.info()).collect(); let active = remembered.and_then(|path| { entries @@ -61,9 +63,9 @@ impl Catalog { .find(|e| e.path == path) .map(|e| e.id.clone()) }); - // From the same snapshot for the same reason: a repository opened - // between two reads would be in the list with no arrangement beside it, - // or have one under an id the list does not carry. + // All from the same snapshot: a repository opened between two reads + // would be in the list with no arrangement beside it, or have one under + // an id the list does not carry. let arrangements = entries .iter() .filter_map(|e| { @@ -71,9 +73,9 @@ impl Catalog { .map(|panel| (e.id.clone(), panel)) }) .collect(); - // And the same again for what each was showing. A project the session - // is not serving keeps its entry on file — there is no id to name it by - // here, and it will want it back when it is opened. + // A project the session is not serving keeps its entry on file — there + // is no id to name it by here, and it will want it back when it is + // opened. let last_views = entries .iter() .filter_map(|e| { @@ -93,18 +95,20 @@ impl Catalog { /// served. The inverse of [`Catalog::get`], for the one caller that stores /// a repository across restarts (`prefs.rs`) and so cannot hold an id. pub fn id_of_path(&self, path: &str) -> Option { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .find(|e| e.path == path) .map(|e| e.id.clone()) } pub fn list(&self) -> Vec { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| e.info()) .collect() @@ -118,9 +122,10 @@ impl Catalog { /// browser's own response builder reads this too, to turn a preference /// stored by path back into the ids it speaks. pub fn id_paths(&self) -> Vec<(String, String)> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| (e.id.clone(), e.path.clone())) .collect() @@ -129,9 +134,10 @@ impl Catalog { /// Absolute worktree paths of the served set, in order. Used to persist the /// open projects. pub fn paths(&self) -> Vec { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| e.path.clone()) .collect() @@ -139,7 +145,11 @@ impl Catalog { #[cfg(test)] pub fn len(&self) -> usize { - self.entries.lock().expect("catalog poisoned").len() + self.runtime + .lock() + .expect("catalog runtime poisoned") + .entries() + .len() } #[cfg(test)] diff --git a/src/session/operations.rs b/src/session/operations.rs index cb636f7d..a6b36e8e 100644 --- a/src/session/operations.rs +++ b/src/session/operations.rs @@ -1,12 +1,7 @@ -//! What the served set of repositories can be asked to do, independent of how -//! the asking arrived. -//! -//! Opening, closing, and reordering are session operations, not HTTP ones. The -//! browser reaches them over HTTP and an attaching client reaches them over the -//! daemon socket, and both must land on exactly the same state change — so the +//! Session operations independent of how the request arrived: the browser and +//! an attaching client must land on exactly the same state change, so the //! change lives here and each transport keeps only its own translation. -//! -//! Nothing here authenticates. Deciding who may ask is the transport's job. +//! Nothing here authenticates — deciding who may ask is the transport's job. use super::SessionState; use crate::session::catalog::{AddOutcome, RepoInfo}; @@ -29,10 +24,9 @@ pub enum CloseError { UnknownRepo, } -/// One repository as an attaching client sees it. -/// -/// Carries the absolute path, which the browser's `RepoDto` deliberately does -/// not: an attached client reads git from that path itself. +/// One repository as an attaching client sees it, with the absolute path the +/// browser's `RepoDto` deliberately omits: an attached client reads git from +/// that path itself. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRepo { pub id: String, @@ -57,17 +51,16 @@ pub fn list_session_repos(state: &SessionState) -> Vec { /// Open `raw_path` and add it to the served catalog. /// -/// The path arrives from outside, so it is expanded, checked, and resolved to -/// the worktree root before the catalog ever sees it — two spellings of one -/// repository must collapse to a single entry. +/// Resolved to the worktree root before the catalog ever sees it: two +/// spellings of one repository must collapse to a single entry. pub fn open_repo(state: &SessionState, raw_path: &str) -> Result { let raw = raw_path.trim(); if raw.is_empty() { return Err(OpenError::EmptyPath); } let expanded = crate::platform::paths::expand_tilde(raw); - // is_dir() follows symlinks and is false for a missing path — either way it - // cannot be served. + // is_dir() follows symlinks and is false for a missing path — either way + // unservable. if !expanded.is_dir() { return Err(OpenError::NotADirectory); } @@ -80,10 +73,9 @@ pub fn open_repo(state: &SessionState, raw_path: &str) -> Result { - // Opening is also a statement about where the client wants to be, so - // it focuses. Every client follows the session's active project, and - // leaving the focus behind would put the tab someone just asked for - // in the background — on their own screen and everyone else's. + // Opening is also a statement about where the client wants to be; + // leaving the focus behind would background the tab someone just + // asked for, on their own screen and everyone else's. if let Some(entry) = state.catalog.get(&repo.id) { state.prefs.set_active_repo(entry.path.clone()); } @@ -124,7 +116,7 @@ fn active_repo_from(state: &SessionState, stored: Option<&str>) -> Option Result<(), CloseError> { let entry = state.catalog.get(id).ok_or(CloseError::UnknownRepo)?; @@ -139,9 +131,8 @@ pub fn accent(state: &SessionState) -> usize { /// Set the session's accent, returning what was stored. /// -/// Shared like the active project rather than kept per surface. An index past -/// the end of the cycle wraps rather than being refused, matching -/// `Accent::from_index`. +/// Shared like the active project. An index past the end of the cycle wraps +/// rather than being refused, matching `Accent::from_index`. pub fn set_accent(state: &SessionState, accent: usize) -> usize { state.prefs.set_accent(accent).accent } @@ -150,56 +141,40 @@ pub fn set_accent(state: &SessionState, accent: usize) -> usize { /// /// The catalog rebuild stops the closed repository's runtime and terminals. /// The updated set is not returned: each transport reads it back in its own -/// projection, and both must do so afterwards anyway since another client can -/// change the set in between. +/// projection afterwards anyway, since another client can change the set. pub fn close_repo(state: &SessionState, id: &str) -> Result<(), CloseError> { let entry = state.catalog.get(id).ok_or(CloseError::UnknownRepo)?; - // One read of the preference, and both the decision and the condition on - // the write are made from it. Reading it twice would leave a gap: a focus - // landing between them would be taken for the value this decided against, - // and then overwritten by a successor chosen before it existed. + // One read of the preference; both the decision and the write condition + // come from it. Reading twice leaves a gap a focus landing in between + // would be mistaken into. // // Not necessarily the closing path, which is why the condition is this - // rather than a comparison against it. Nothing may have been selected at - // all, in which case the project in front is the first served one and the - // preference is still empty; or it may name a project this session does not - // serve. The successor has to be recorded in both, and comparing against - // the closing path skipped them silently — the fallback answered correctly - // for as long as the successor stayed first, then handed the front to - // whatever had taken its place. + // rather than a comparison against it: the preference may be empty, or + // name a project this session does not serve. Comparing against the + // closing path skipped those silently — the fallback answered correctly + // only while the successor stayed first. let focus_before = state.prefs.get().active_repo; - // Read before the close, because it is a position in the set that is about - // to change, and only interesting when the tab being closed is the one in - // front — closing a background project must leave the focus where it is. + // Read before the close: it is a position in the set about to change, and + // only interesting when the closing tab is the one in front. let successor = (active_repo_from(state, focus_before.as_deref()).as_deref() == Some(id)) .then(|| successor_of(state, id)) .flatten(); state.catalog.remove_path(&entry.path); - // Said outright rather than left to `active_repo`'s fallback. That fallback - // answers "nothing has been focused yet, or what is on file is no longer - // served" with the first repository, which is right for a fresh session and - // wrong for a close: it sent everyone to the first tab from wherever they - // were. The TUI has picked the neighbour since it had tabs - // (`workspace::close_at`) and was overruled by this a beat later. + // Said outright rather than left to `active_repo`'s fallback, which sends + // a close to the first tab from wherever they were; the TUI has picked the + // neighbour since it had tabs (`workspace::close_at`). if let Some(path) = successor - // Still served. The successor was read from the set before the close, - // and another client can have closed it in between — recording a path - // nothing resolves would leave every surface on `active_repo`'s - // fallback, which is the first tab this exists to stop landing on. + // Still served — another client can have closed it in between, and + // recording a path nothing resolves would land everyone on the first + // tab fallback this exists to stop. && state.catalog.id_of_path(&path).is_some() { - // Only while the preference is still what it was when this decided. - // Compared inside the preference store's own locked write, so against - // another focus it is atomic; what it cannot see is the catalog, which - // has a lock of its own that this must not hold at the same time. - // - // What this guarantees is that *the close* does not overwrite a focus - // made meanwhile — not that such a focus survives. A browser that - // closed the tab then records where it landed, from the one place its - // selection settles (`useRepoPoll`), and that write is a client saying - // where it is rather than a close deciding for everyone. Last assertion - // wins there, as it does for every other switch. A TUI close asserts - // nothing after the fact, so for it this holds outright. + // Only while the preference is still what it was when this decided — + // atomic against another focus inside the preference store's locked + // write, but it cannot see the catalog, whose lock this must not hold + // at the same time. Guarantees *the close* does not overwrite a focus + // made meanwhile, not that such a focus survives: a browser records + // where it landed and last assertion wins, as for every other switch. state .prefs .set_active_repo_if(focus_before.as_deref(), path); @@ -209,16 +184,12 @@ pub fn close_repo(state: &SessionState, id: &str) -> Result<(), CloseError> { } /// The tab to put in front once `id` closes: the one after it, or the one -/// before when it is last. Its *path*, because that is what the preference -/// stores and the id is about to stop naming anything. -/// -/// The same rule browsers use, and the same one `workspace::close_at` already -/// applies on the TUI — so the answer this records is the one that client had -/// picked for itself, and adopting it moves nothing. +/// before when it is last. Its *path*, because the id is about to stop naming +/// anything. The same rule `workspace::close_at` applies on the TUI, so +/// adopting it moves nothing. /// -/// `None` when the set holds nothing else, which is the empty screen. Nothing -/// is written then: there is no tab to name, and the stale entry costs nothing -/// because it no longer resolves. +/// `None` when the set holds nothing else; nothing is written then, and the +/// stale entry costs nothing because it no longer resolves. fn successor_of(state: &SessionState, id: &str) -> Option { let served = state.catalog.id_paths(); let closing = served.iter().position(|(served_id, _)| served_id == id)?; @@ -231,8 +202,7 @@ fn successor_of(state: &SessionState, id: &str) -> Option { /// Reorder the catalog to `ids`. /// /// Ids that no longer name a repository are dropped rather than refused: the -/// only way to send one is to have raced a close on another client, and the -/// catalog canonicalizes the requested order against what is actually live. +/// only way to send one is to have raced a close on another client. pub fn reorder_repos(state: &SessionState, ids: &[String]) { let paths: Vec = ids .iter() @@ -244,9 +214,8 @@ pub fn reorder_repos(state: &SessionState, ids: &[String]) { /// Mirror the served set into the shared workspace file so the next launch /// starts with the same projects. No-op unless the server was started with -/// `persist` (headless `serve`); alongside the TUI, the TUI owns that file. The -/// existing per-repo view state and active tab are preserved; only the -/// open-repo list is rewritten. +/// `persist` (headless `serve`); alongside the TUI, the TUI owns that file. +/// Only the open-repo list is rewritten. fn persist_workspace(state: &SessionState) { if !state.persist { return; diff --git a/src/session/prefs/mod.rs b/src/session/prefs/mod.rs index 7c7597f4..dac37070 100644 --- a/src/session/prefs/mod.rs +++ b/src/session/prefs/mod.rs @@ -1,6 +1,5 @@ -//! Preferences that follow the user rather than the browser they arrived in. -//! -//! Stored in `~/.nightcrow/viewer.json`. The accent is the session's (shared +//! Preferences that follow the user rather than the browser they arrived in, +//! stored in `~/.nightcrow/viewer.json`. The accent is the session's (shared //! with an attached TUI); `sidebar_width` and `upper_pct` are the viewer's alone //! — the first has no TUI counterpart, and the second is deliberately not shared //! because a percentage means different things on a terminal vs a browser window. @@ -39,17 +38,15 @@ pub struct ViewerPrefs { pub accent: usize, /// File-sidebar width in CSS px, clamped to `[MIN, MAX]`. pub sidebar_width: u32, - /// Share of the vertical split given to the diff panel, in percent. - /// - /// The viewer's own, not the session's — unlike the accent. A percentage - /// means different things on a terminal vs a browser window, so sharing with - /// the TUI's `layout.upper_pct` was rejected. + /// Share of the vertical split given to the diff panel, in percent. The + /// viewer's own, not the session's — a percentage means different things on + /// a terminal vs a browser window, so sharing with the TUI's + /// `layout.upper_pct` was rejected. pub upper_pct: u32, - /// Absolute worktree path of the last-selected project. - /// - /// A **path**, not the repo id: ids only live as long as the process, so a - /// stored id would name nothing after a restart. The server translates; the - /// client never learns the path. `None` until a client selects a project. + /// Absolute worktree path of the last-selected project. A **path**, not the + /// repo id: ids only live as long as the process, so a stored id would name + /// nothing after a restart. The server translates; the client never learns + /// the path. `None` until a client selects a project. pub active_repo: Option, /// Which panel each project was left maximized in, most recently set first. pub maximized: Vec, diff --git a/src/session/prefs/repo_view.rs b/src/session/prefs/repo_view.rs index 53b842e5..8b2f3466 100644 --- a/src/session/prefs/repo_view.rs +++ b/src/session/prefs/repo_view.rs @@ -1,21 +1,18 @@ //! What each project was last showing in the browser, so opening it again //! opens what was open. //! -//! The TUI has kept this per repository since it had a session file — mode, the -//! selected file, the tree's cursor and its expanded directories +//! The TUI has kept this per repository since it had a session file //! (`app::session_io`). This is the same thing for the viewer, and deliberately //! not the same *file*: `workspace.json` belongs to the TUI, which rewrites it -//! whole when it exits (`session::operations::persist_workspace` says so), so an -//! entry written here would go the next time a TUI ran. Kept in `viewer.json` -//! beside `maximized`, which is per-project for the same reason and keyed the -//! same way — by absolute path, because repo ids only live as long as the -//! process. +//! whole when it exits, so an entry written here would go the next time a TUI +//! ran. Kept in `viewer.json` beside `maximized`, keyed the same way — by +//! absolute path, because repo ids only live as long as the process. use serde::{Deserialize, Serialize}; /// How many projects' views to remember. Past this the oldest go. Matches the -/// TUI's `MAX_REMEMBERED` and `maximized`'s cap for the same reason: a file -/// that grows with every project ever glanced at. +/// TUI's `MAX_REMEMBERED` and `maximized`'s cap: a file that grows with every +/// project ever glanced at. pub const MAX_REMEMBERED_VIEWS: usize = 50; /// How many expanded directories one project may keep. A tree opened all the diff --git a/src/session/reload.rs b/src/session/reload.rs index 98c2fc9e..cff37207 100644 --- a/src/session/reload.rs +++ b/src/session/reload.rs @@ -1,22 +1,19 @@ //! Re-reading `config.toml` into a running session, independent of how the //! asking arrived. //! -//! Sits beside [`session`](super::session) and for the same reason: the browser -//! reaches this over HTTP and an attached terminal over the daemon socket, and -//! both must land on exactly the same state change. Neither transport -//! authenticates here — deciding who may ask is theirs. +//! Sits beside [`session`](super::session) for the same reason: the browser and +//! an attached terminal must land on exactly the same state change. Neither +//! transport authenticates here. //! //! **What a reload is, and what it is not.** It re-reads two tables and nothing //! else. `[[plugin]]` reaches even the repositories that are already open, -//! because a plugin is a child process and replacing one costs the session -//! nothing. `[[startup_command]]` reaches only the repositories opened -//! afterwards: a hub creates its startup panes once for its life, and the panes -//! a running repository already spent that list on are live children that no -//! file edit may replace. Everything else in the file is read once at startup -//! and still needs a restart. +//! because replacing a plugin child costs the session nothing. +//! `[[startup_command]]` reaches only repositories opened afterwards: a hub +//! creates its startup panes once for its life, and the live children a running +//! repository already spent that list on no file edit may replace. //! -//! **It does not half-apply.** The whole file is parsed and validated first, so a -//! typo anywhere leaves the session exactly as it was. +//! **It does not half-apply.** The whole file is parsed and validated first, so +//! a typo anywhere leaves the session exactly as it was. use super::SessionState; @@ -56,8 +53,7 @@ impl ReloadReport { /// being opened. /// /// Written here rather than in each client because both surfaces show the - /// same sentence — a toast in the browser, a notice in the TUI — and two - /// wordings of the same outcome would drift. + /// same sentence, and two wordings of the same outcome would drift. pub fn summary(&self) -> String { let panes = if self.startup_commands == 0 { "no startup panes configured".to_string() @@ -123,15 +119,15 @@ pub fn reload_config_at( .set_config_tables(&cfg.startup_commands, cfg.plugins.clone()) .map_err(ReloadError::Config)?; - // Then the repositories already open. Each hub is *asked* — the work happens - // on its own worker thread, which is the only thread allowed to touch a - // plugin child — so this returns before the children have finished being - // replaced. That is deliberate: waiting would mean blocking whoever asked on - // every repository's queue. + // Then the repositories already open. Each hub is *asked* — the work + // happens on its own worker thread, the only thread allowed to touch a + // plugin child — so this returns before the children have been replaced. + // Deliberate: waiting would block whoever asked on every repository's + // queue. // // A hub too far behind to take the request is counted rather than retried: - // its queue being full means its worker is wedged or being hammered, and - // neither blocking on it nor pretending it complied is honest. It keeps the + // a full queue means its worker is wedged or being hammered, and neither + // blocking on it nor pretending it complied is honest. It keeps the // plugins it had, and the report says so. let mut unreachable = 0; for entry in &entries { @@ -139,10 +135,9 @@ pub fn reload_config_at( continue; } unreachable += 1; - // Which repository, logged here rather than in the hub — the hub does not - // keep its own path, and the summary is one sentence for a person, too - // short to carry a list. The operator who reads "1 was too busy" finds - // the name here. + // Which repository, logged here: the hub does not keep its own path, + // and the summary is one sentence for a person, too short to carry a + // list. tracing::warn!( repo = %entry.path, "session: a repository's queue was full; its plugins were not re-applied" diff --git a/src/session/runtime/mod.rs b/src/session/runtime/mod.rs index fd2dfc13..d6233acf 100644 --- a/src/session/runtime/mod.rs +++ b/src/session/runtime/mod.rs @@ -139,10 +139,10 @@ impl RepoRuntime { /// status, so a fresh connection renders immediately instead of waiting for /// the next change. /// - /// The first subscriber also starts the watch, and is answered from a reading - /// taken here rather than from `latest` — while the watch was off, `latest` - /// is whatever was true when the last client left, which on a page opened - /// the next morning is not a stale detail but a wrong screen. + /// The first subscriber also starts the watch, and is answered from a + /// reading taken here rather than from `latest` — while the watch was off, + /// `latest` is whatever was true when the last client left, which is a + /// wrong screen rather than a stale detail. pub fn subscribe(self: &Arc) -> Subscription { let id = self.next_subscriber_id.fetch_add(1, Ordering::AcqRel); let slot = Arc::new(Mutex::new(None)); @@ -171,11 +171,11 @@ impl RepoRuntime { // Outside the lock, which publishing takes. self.read_and_publish(); } - // Whatever the publish did not leave here: a repository unchanged since - // the last client left publishes nothing, and this subscriber would - // render an empty page until something happened. Read before the slot is - // locked, never while — `publish` holds `latest` and reaches for slots, - // so taking them the other way round is the two halves of a deadlock. + // Whatever the publish did not leave here: a repository unchanged + // since the last client left publishes nothing, and this subscriber + // would render an empty page until something happened. Read before + // the slot is locked, never while — `publish` holds `latest` and + // reaches for slots, so the other order is a deadlock. let seed = self.latest(); let mut held = slot.lock().expect("subscriber slot poisoned"); if held.is_none() { @@ -193,14 +193,13 @@ impl RepoRuntime { fn unsubscribe(&self, id: u64) { // Under the same hold of the lock as the removal, for the reason given - // in `subscribe`: a watch decision taken after letting go of the list can - // be overtaken by one taken while holding it. + // in `subscribe`: a watch decision taken after letting go of the list + // can be overtaken by one taken while holding it. let mut subscribers = self.subscribers.lock().expect("subscribers poisoned"); subscribers.retain(|s| s.id != id); if subscribers.is_empty() { - // Nobody is reading, so stop walking the tree. What was published - // stays in `latest` for anything that asks over REST; the next - // subscriber replaces it with a reading before it is served (see + // Nobody is reading, so stop walking the tree. The next subscriber + // replaces `latest` with a reading before it is served (see // `subscribe`). self.watch.set_awake(false); } diff --git a/src/session/size_owner.rs b/src/session/size_owner.rs index 7767b02d..e3b29f3e 100644 --- a/src/session/size_owner.rs +++ b/src/session/size_owner.rs @@ -7,23 +7,19 @@ //! //! **Why this is the session's and not each hub's.** Which repository is in //! front is shared by the whole session, so "which screen is this session fitted -//! to" is one question, not one per repository. Asked per hub, it was re-answered -//! from scratch on every switch — a browser's terminal socket is tied to the -//! repository it shows, so moving tabs made every attached page reconnect at once -//! and the sizing fell to whichever handshake finished last. +//! to" is one question. Asked per hub, it was re-answered on every switch — +//! moving tabs made every attached page reconnect at once and the sizing fell +//! to whichever handshake finished last. //! //! **A viewer is not a connection.** A socket opens for reasons that are not a -//! person sitting down: a repository switch, a page reload, a network blip. So a -//! viewer names itself ([`ViewerId`]) and says outright whether it is newly -//! arrived; the session never infers it. Connections come and go beneath a -//! viewer without moving anything. +//! person sitting down: a repository switch, a page reload, a network blip. So +//! a viewer names itself ([`ViewerId`]) and says outright whether it is newly +//! arrived; connections come and go beneath a viewer without moving anything. //! -//! **Unowned means empty.** The sizing has no owner only while nobody is here: -//! there is no screen to fit, so the panes keep the size they have. The moment -//! a viewer is present, one of them owns it. A session with a person in it and -//! nobody sizing for them is not a state worth having — it renders their panes -//! at a departed screen's size and makes them press the fit button to undo it, -//! which is what a phone did every time it woke up. +//! **Unowned means empty.** The sizing has no owner only while nobody is here. +//! A session with a person in it and nobody sizing for them renders their panes +//! at a departed screen's size — the state a phone produced every time it woke +//! up. //! //! This file is the facade — locking, and the contract each caller sees. The //! rules themselves live with the state they read, in [`state`]. @@ -42,10 +38,10 @@ use state::Inner; /// How long the sizing is held for an owner that has no connection left. /// -/// Switching repositories closes one terminal socket and opens another, and for -/// the moment in between the owner is not connected to anything. Handing the -/// sizing away there and back again would re-fit every pane twice for a viewer -/// that never went anywhere. Only the *release* is delayed; nothing claims by +/// Switching repositories closes one terminal socket and opens another, and +/// for the moment in between the owner is connected to nothing. Handing the +/// sizing away there and back would re-fit every pane twice for a viewer that +/// never went anywhere. Only the *release* is delayed; nothing claims by /// waiting. pub const RELEASE_GRACE: Duration = Duration::from_secs(2); diff --git a/src/session/size_owner_audit.rs b/src/session/size_owner_audit.rs index 340edf24..dfe98eef 100644 --- a/src/session/size_owner_audit.rs +++ b/src/session/size_owner_audit.rs @@ -1,16 +1,10 @@ -//! What the sizing did, written down. -//! -//! Which screen the PTYs are fitted to changes what *every* attached client -//! renders, and a client that loses it stays wrong until a person presses the -//! fit button. It also moves for reasons no client can observe — a viewer's -//! last connection going, a grace expiring on a worker tick — so with nothing -//! recorded there is only the symptom to read afterwards. A phone that kept -//! coming back a spectator had to be diagnosed by reasoning backwards from the -//! button, because none of this was written anywhere. -//! -//! INFO rather than DEBUG: these happen per page load and per repository -//! switch, not per frame, and the moment they are wanted is a report about -//! something that already happened — which is too late to raise the level. +//! What the sizing did, written down. It changes what *every* attached client +//! renders, it moves for reasons no client can observe (a last connection +//! going, a grace expiring on a worker tick), and a client that loses it stays +//! wrong until a person presses the fit button — so with nothing recorded there +//! is only the symptom to read afterwards. INFO rather than DEBUG: these happen +//! per page load, not per frame, and the moment they are wanted is a report +//! about something that already happened — too late to raise the level. use super::ViewerId; diff --git a/src/session/size_owner_state.rs b/src/session/size_owner_state.rs index a7befd22..f5826646 100644 --- a/src/session/size_owner_state.rs +++ b/src/session/size_owner_state.rs @@ -1,10 +1,8 @@ //! The bookkeeping behind [`SizeOwnership`](super::SizeOwnership), and every -//! rule that reads or writes it. -//! -//! Split from the facade so that all of it happens where the fields live: the -//! rules are a handful of interlocking conditions over who is present, who owns -//! the sizing and how long it has been unattended, and spreading them across a -//! module boundary would mean opening those fields up to reach them. +//! rule that reads or writes it. Split from the facade so the rules — a handful +//! of interlocking conditions over presence, ownership and idle time — live +//! where the fields are, rather than opening those fields across a module +//! boundary. use super::{RELEASE_GRACE, Registration, ViewerId, audit}; use crate::session::terminal::frame::{ServerMessage, TerminalFrame}; diff --git a/src/session/terminal/frame.rs b/src/session/terminal/frame.rs index 5d38a8e3..abf33710 100644 --- a/src/session/terminal/frame.rs +++ b/src/session/terminal/frame.rs @@ -106,23 +106,22 @@ impl PaneSize { #[serde(tag = "type", rename_all = "lowercase")] pub enum ServerMessage { /// A pane exists, along with the size its PTY is currently set to. The size - /// rides along because the client is not the only source of it: a pane - /// replayed to a reconnecting page, or one another device sized, already has - /// a size this client never chose. Without it the client must assume nothing - /// and send its own size on attach, costing the child a full repaint. + /// rides along because the client is not the only source of it — a pane + /// another device sized already has a size this client never chose. + /// Without it the client must send its own size on attach, costing the + /// child a full repaint. Created { pane: PaneId, rows: u16, cols: u16, /// Which client asked for this pane, in the id space of the connection - /// the frame is going out on. Each recipient compares it against its own - /// id on that connection. `None` means nobody there asked: a replayed - /// pane, one another client opened, or a startup terminal. + /// the frame is going out on; each recipient compares it against its + /// own. `None` means nobody there asked: a replayed pane, one another + /// client opened, or a startup terminal. #[serde(default, skip_serializing_if = "Option::is_none")] client: Option, /// What the session calls this pane, when it has a name of its own — a - /// startup terminal opened under a configured name. Absent for a pane a - /// client asked for, and for one nothing has named. + /// startup terminal opened under a configured name. #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, }, @@ -138,17 +137,16 @@ pub enum ServerMessage { cols: u16, }, /// Who this client is, in the id space [`Created::client`] is stamped in. - /// Addressed, and the first thing a connection is told. A connection's id, - /// not a viewer's: minted per connection and a reconnect gets a new one. + /// A connection's id, not a viewer's: minted per connection and a + /// reconnect gets a new one. /// /// [`Created::client`]: Self::Created::client Hello { client: u64, /// How many `Created` frames the replay is about to deliver. Exact, /// because `connect` queues the whole replay under the hub's lock and - /// only registers the client afterwards. A client that knows the count - /// can lay its grid out for the panes it is *going* to have rather than - /// the ones it has so far. + /// only registers the client afterwards — a client that knows the + /// count can lay its grid out for the panes it is *going* to have. panes: usize, }, /// Whether *this* client is the one whose layout sets the pane sizes. @@ -182,13 +180,11 @@ pub enum ServerMessage { }, /// What a plugin reports about a pane it is nursing back, relayed verbatim. /// - /// Pane metadata rather than screen content: nothing here is drawn into a - /// terminal grid, and a client that ignores it renders exactly as before. - /// `state` is the plugin's own short label; the hub neither interprets it nor - /// keeps it, so this is a broadcast of the latest word and not a state - /// machine. The one label the hub itself sends is - /// [`RECOVERY_CANCELLED`](super::hub_recovery::RECOVERY_CANCELLED), which a - /// client treats as "there is nothing pending any more". + /// Pane metadata rather than screen content: a client that ignores it + /// renders exactly as before. `state` is the plugin's own short label; + /// the hub neither interprets it nor keeps it, so this is a broadcast of + /// the latest word and not a state machine. The one label the hub itself + /// sends is [`RECOVERY_CANCELLED`](super::hub_recovery::RECOVERY_CANCELLED). /// A plugin says this pane wants the person back. Carries no reason and no /// text: the client turns it into that project tab's unread marker, which /// says "something happened here" and nothing more. diff --git a/src/session/terminal/hub_connect.rs b/src/session/terminal/hub_connect.rs index 7a7b6d26..74fc1f15 100644 --- a/src/session/terminal/hub_connect.rs +++ b/src/session/terminal/hub_connect.rs @@ -13,28 +13,18 @@ use std::time::Instant; impl TerminalHub { /// Register a client and put the current terminals in front of it before it - /// is eligible for broadcasts. - /// - /// Per live pane: a `Created`, the modes its program has set - /// ([`PaneModes::prelude`](crate::runtime::emulator::PaneModes::prelude)), and - /// then that pane's screen — its recorded bytes anchored to the serialized - /// screen they build on, or for a program drawing on the alternate screen - /// that serialized screen and what is owed on top of it (see - /// [`replay_pane`]). Done under the state lock so this snapshot cannot - /// interleave with the worker's append-and-broadcast (see - /// [`Shared`](super::hub_helpers::Shared)); the client therefore receives every - /// pane's screen exactly once and in order ahead of the live stream. A fresh - /// hub (e.g. after a server restart) has no panes, so a reconnecting client - /// correctly comes back to an empty panel. + /// is eligible for broadcasts: per live pane, a `Created`, the modes its + /// program has set, and then that pane's screen (see [`replay_pane`]). Done + /// under the state lock so this snapshot cannot interleave with the worker's + /// append-and-broadcast — the client receives every pane's screen exactly + /// once, in order, ahead of the live stream. /// /// `viewer` names who this connection belongs to and `arriving` says whether - /// a person just sat down at it — a page opening rather than a repository - /// switch or a reconnect. Only the second takes the sizing; see - /// [`SizeOwnership`](crate::session::size_owner::SizeOwnership). - /// - /// `socket` is a handle on the connection to end if this client stops - /// keeping up, and `None` for one that has no socket here at all — see - /// [`Client::socket`](super::session::Client::socket). + /// a person just sat down at it; only the second takes the sizing (see + /// [`SizeOwnership`](crate::session::size_owner::SizeOwnership)). `socket` + /// is the handle used to end the connection if this client stops keeping up, + /// `None` for a client with no socket here (see + /// [`Client::socket`](super::session::Client::socket)). pub fn connect( self: &Arc, viewer: ViewerId, @@ -63,18 +53,16 @@ impl TerminalHub { let _ = tx.try_send(TerminalFrame::Control(json)); } if replaying { - // Ahead of the panes, though it names one of them. A client holds - // its outbound resize until the layout stops moving, and replaying - // several panes' histories can outlast that wait — so a page that - // learned the zoom last could settle on the grid, size every PTY to - // a cell, and then resize them all again when the zoom arrived. - // That is two SIGWINCH repaints for every client, which is the cost - // `Created` carrying its pane's size exists to avoid. + // Ahead of the panes, though it names one of them. Replaying + // several panes' histories can outlast a client's wait for the + // layout to stop moving, so a page that learned the zoom last + // would settle on the grid, size every PTY to a cell, and then + // resize them all again — two SIGWINCH repaints per client, the + // cost `Created` carrying its pane's size exists to avoid. // - // Safe in this order because the panel derives what it renders from - // the pane list it has: a zoom naming a pane not delivered yet - // simply does not apply until that pane arrives. Sent only when - // something is zoomed — nothing zoomed is where a client starts. + // Safe in this order: a zoom naming a pane not delivered yet does + // not apply until that pane arrives. Sent only when something is + // zoomed — nothing zoomed is where a client starts. if let Some(pane) = state.zoomed && let Ok(json) = serde_json::to_string(&ServerMessage::Zoomed { pane: Some(pane) }) { @@ -99,11 +87,9 @@ impl TerminalHub { "viewer: replaying a pane's record" ); if !replay_pane(&tx, pane) { - // The queue is this client's own and empty until now, and a - // whole replay of the largest panes allowed fits it (see - // `REPLAY_CHUNK_BYTES`) -- so this is a broken assumption - // rather than a busy moment, and the client is left showing a - // screen with a hole in it that nothing else would explain. + // The queue is this client's own and empty until now, and + // a whole replay of the largest panes allowed fits it — + // so this is a broken assumption, not a busy moment. tracing::warn!( pane = pane.id, client = id, @@ -177,21 +163,39 @@ impl TerminalHub { /// Unregister a session that is going away. /// /// `connection` comes from the session rather than from the client record, - /// because the record may already be gone: every eviction path removes it - /// the moment the client stops keeping up. Reading the registration out of - /// the list meant an evicted client never released the sizing — it stayed - /// present forever, so a viewer that had it kept it after its page had - /// closed and no other screen could take it back. + /// which may already be gone: every eviction path removes it the moment + /// the client stops keeping up. Reading the registration out of the list + /// meant an evicted client never released the sizing — no other screen + /// could take it back after its page had closed. pub(super) fn disconnect(&self, id: u64, connection: u64) { - self.state - .lock() - .expect("terminal state poisoned") - .clients - .retain(|c| c.id != id); + #[cfg(test)] + let mut state = match self.state.try_lock() { + Ok(state) => { + self.run_concurrency_test_hook( + super::ConcurrencyTestPoint::DisconnectStateAcquired, + ); + state + } + Err(std::sync::TryLockError::WouldBlock) => { + self.run_concurrency_test_hook( + super::ConcurrencyTestPoint::DisconnectStateContended, + ); + self.state.lock().expect("terminal state poisoned") + } + Err(std::sync::TryLockError::Poisoned(_)) => panic!("terminal state poisoned"), + }; + #[cfg(not(test))] + let mut state = self.state.lock().expect("terminal state poisoned"); + state.clients.retain(|c| c.id != id); + drop(state); // Off the hub's lock: what happens to the sizing is the session's // business, and it may have to tell clients on other hubs. Unconditional // — `leave` ignores a connection it does not know, which is the case // when this runs twice for one session. self.ownership.leave(connection, Instant::now()); + // After `leave`: `queue_resize` checks ownership while holding this + // queue's lock, so a racing request is either inserted before this + // purge or rejected after the connection is no longer registered. + self.discard_pending_resizes(connection); } } diff --git a/src/session/terminal/hub_diag.rs b/src/session/terminal/hub_diag.rs index c42ae78f..f82e61f2 100644 --- a/src/session/terminal/hub_diag.rs +++ b/src/session/terminal/hub_diag.rs @@ -1,17 +1,10 @@ -//! Recording where a pane's screen-clearing input came from. -//! -//! This exists because of a specific unexplained event: a pane running Claude -//! Code had its conversation cleared fourteen times in five seconds. Claude Code -//! runs `/clear` when it receives `Ctrl+L` twice within two seconds, and the -//! transcript showed the clears arriving as a shortcut rather than as typed -//! input — so `0x0c` reached the pane about thirty times, at a machine-like -//! cadence, and nobody knows what sent it. nightcrow itself does not: the only -//! bytes it synthesizes are scroll and mouse reports and a plugin's `continue`, -//! and that one is logged where it happens. That leaves a client's own input. -//! -//! So this notes the arrival and its shape, and the client says what produced it -//! (`ClientMessage::ClearKeyReport`, logged in `session.rs`). Between them, the -//! next occurrence names its source instead of being reconstructed afterwards. +//! Recording where a pane's screen-clearing input came from. This exists +//! because of a specific unexplained event — a pane's conversation cleared +//! fourteen times in five seconds, the clears arriving as `0x0c` at a +//! machine-like cadence nobody could attribute. nightcrow does not synthesize +//! that byte, which leaves a client's own input: so this notes the arrival and +//! its shape, and the client says what produced it +//! (`ClientMessage::ClearKeyReport`, logged in `session.rs`). //! //! **No input content is logged, ever** — only the byte's count, how much else //! rode with it, and the timing. diff --git a/src/session/terminal/hub_helpers.rs b/src/session/terminal/hub_helpers.rs index 596731cb..553b3cd0 100644 --- a/src/session/terminal/hub_helpers.rs +++ b/src/session/terminal/hub_helpers.rs @@ -17,9 +17,8 @@ pub enum Command { command: Option, }, /// Every startup pane in one command, so queueing the set is all-or-nothing. - /// `reserved` is how many cap slots [`Shared::reserved`] is holding for this - /// batch, released as the panes take them. The reservation keeps other - /// clients' creates from taking slots the configured set already claimed. + /// `reserved` holds cap slots [`Shared::reserved`] is keeping for this + /// batch, so other clients' creates cannot take them first. CreateStartup { panes: Vec, client: u64, @@ -32,33 +31,45 @@ pub enum Command { data: Vec, client: u64, }, - /// `client` rides along because a resize is only honoured from the client - /// that owns the sizing (see [`Shared::size_owner`]). - Resize { - pane: PaneId, - rows: u16, - cols: u16, - client: u64, - }, Close { pane: PaneId, }, Reorder { order: Vec, }, - /// Abandon a pane's pending relaunch. On the worker queue because carrying it - /// out needs the backend and the plugin bookkeeping, both worker-local. + /// On the worker queue because carrying it out needs the backend and the + /// plugin bookkeeping, both worker-local. CancelRecovery { pane: PaneId, }, - /// Bring this hub's plugin children in line with a re-read `[[plugin]]` - /// table. On the queue because every plugin host is worker-local — a plugin - /// can drive a pane's keyboard, so nothing outside the worker may touch one. + /// On the queue because every plugin host is worker-local — a plugin can + /// drive a pane's keyboard, so nothing outside the worker may touch one. ReloadPlugins { plugins: Vec, }, } +/// The newest size one connection wants for one pane. Resize traffic is kept +/// out of the bounded command queue: intermediate drag positions may collapse, +/// but the final position must remain available to the worker. +pub(super) struct PendingResize { + pub(super) pane: PaneId, + pub(super) rows: u16, + pub(super) cols: u16, + pub(super) client: u64, + pub(super) connection: u64, +} + +const COMMANDS_BETWEEN_RESIZES: usize = 64; + +/// Whether a continuously ready command stream has reached the point where +/// pending geometry must run before this next command. +pub(super) fn resize_due_before_command(commands_since_resize: &mut usize) -> bool { + let due = *commands_since_resize == COMMANDS_BETWEEN_RESIZES; + *commands_since_resize = if due { 1 } else { *commands_since_resize + 1 }; + due +} + /// One startup terminal: the command to run, at the size a client measured, under /// the name it was configured with. pub struct StartupPane { @@ -74,58 +85,51 @@ pub struct StartupPane { /// A live terminal and what a client that connects has to be given to see it. /// -/// Which record is the pane's screen depends on the mode its program is in, and -/// only one side is written at a time: +/// Replay composition, per mode (only one side is written at a time): /// -/// - **Normal screen** — `scrollback`, the raw bytes the pane has produced, with -/// `normal_screen` + `covered` marking a serialized screen partway through -/// them. The ring alone was the record once, but it is byte-bounded and a -/// program that repaints in place — a prompt box, a spinner, a status line — -/// rotates it without ever scrolling: after a long idle the bytes that painted -/// the top of the screen had been evicted, and a replay rebuilt only the -/// repeatedly-redrawn bottom. So replay is `scrollback[..covered]` (history), -/// then `normal_screen` (the screen as of that point, an absolute repaint), -/// then `scrollback[covered..]` — the front of the ring may be evicted freely -/// and the screen still arrives whole (see +/// - **Normal screen** — `scrollback` plus `normal_screen` + `covered`. The +/// ring alone was not enough: a program that repaints in place rotates it +/// without scrolling, so after a long idle the bytes painting the top of the +/// screen had been evicted and a replay rebuilt only the redrawn bottom. +/// Replay is `scrollback[..covered]`, then `normal_screen` (absolute +/// repaint), then `scrollback[covered..]` — the front of the ring may be +/// evicted freely and the screen still arrives whole (see /// [`replay_pane`](super::hub_replay::replay_pane)). /// - **Alternate screen** — `screen` + `since`. The raw bytes are cell updates -/// against a screen a new client does not have, so what is kept instead is the -/// screen itself, serialized (`hub_modes::PaneModeTracker::snapshot`). While a -/// program is on the alternate screen the normal-screen record is left frozen, -/// holding the screen it will be returned to. +/// against a screen a new client does not have, so the screen itself is +/// kept, serialized. The normal-screen record is left frozen while a +/// program is on the alternate screen. pub(super) struct PaneState { pub(super) id: PaneId, - /// What this pane goes by: the name the session gave a configured startup - /// terminal, and then whatever its program has titled itself since (OSC 0/2, - /// followed in [`hub_modes`](super::hub_modes)). Kept so a client that - /// connects later is told it too — the bytes that set it leave `scrollback` - /// within seconds, so nothing else could tell that client. + /// The name the session gave a configured startup terminal, then whatever + /// its program has titled itself since (OSC 0/2, followed in + /// [`hub_modes`](super::hub_modes)). Kept because the bytes that set it + /// leave `scrollback` within seconds — a later-connecting client could not + /// learn it any other way. pub(super) title: Option, pub(super) scrollback: VecDeque, - /// The pane's normal screen as of `covered` bytes into `scrollback`, - /// serialized the way `screen` is. Empty until the worker first takes one — - /// a ring that has never evicted rebuilds the screen on its own. + /// The pane's normal screen as of `covered` bytes into `scrollback`. + /// Empty until the worker first takes one — a ring that has never evicted + /// rebuilds the screen on its own. pub(super) normal_screen: Vec, - /// How many bytes at the front of `scrollback` `normal_screen` accounts for. - /// Only those may be evicted: they are history whose effect on the screen the - /// snapshot already carries. The bytes past the mark are what a replay - /// applies *on top of* the snapshot, and dropping any of them would hand a - /// connecting client a screen missing an update nothing would ever repair. + /// How many bytes at the front of `scrollback` `normal_screen` accounts + /// for. Only those may be evicted: dropping any byte past the mark would + /// hand a connecting client a screen missing an update nothing repairs. pub(super) covered: usize, - /// This pane's screen as of the last snapshot, empty unless its program is on - /// the alternate screen. + /// This pane's screen as of the last snapshot, empty unless its program is + /// on the alternate screen. pub(super) screen: Vec, - /// Bytes broadcast since `screen` was taken. A snapshot is refreshed once per - /// worker tick, so a client can connect between the broadcast of a chunk and - /// the refresh that accounts for it; replaying `screen` then `since` is what - /// makes the two add up to exactly what every other client has seen. + /// Bytes broadcast since `screen` was taken. A client can connect between + /// the broadcast of a chunk and the snapshot refresh that accounts for it; + /// replaying `screen` then `since` is what makes the two add up to exactly + /// what every other client has seen. /// /// **Never dropped, only superseded.** Terminal bytes cannot be skipped, so /// outgrowing [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`] forces a fresh /// snapshot (which empties this) rather than evicting from the front. pub(super) since: VecDeque, - /// The size the PTY is currently set to, tracked so a connecting client - /// learns it and can skip a resize that would change nothing. + /// The size the PTY is currently set to, so a connecting client can skip a + /// resize that would change nothing. pub(super) rows: u16, pub(super) cols: u16, /// The terminal state the pane's program has established, kept because the @@ -134,16 +138,15 @@ pub(super) struct PaneState { pub(super) modes: PaneModes, } -/// Hub state shared between the worker thread (which mutates panes and -/// broadcasts) and connection threads (which register/unregister clients and -/// snapshot scrollback on connect). Held under one mutex so a connecting -/// client's replay is atomic with the worker's append-and-broadcast. +/// Hub state shared between the worker thread and connection threads. Held +/// under one mutex so a connecting client's replay is atomic with the worker's +/// append-and-broadcast. pub struct Shared { pub(super) clients: Vec, pub(super) panes: Vec, - /// Cap slots held for startup panes that are claimed but not created yet. - /// Counted against the same cap rather than exempt from it, so the ceiling - /// on real processes per repository stays what it says it is. + /// Cap slots held for startup panes that are claimed but not created yet, + /// counted against the same cap rather than exempt from it — otherwise the + /// ceiling on real processes per repository would not hold. pub(super) reserved: usize, /// The pane filling the panel, when one is (see [`hub_zoom`](super::hub_zoom)). /// Beside `panes` and under the same lock because the two have to agree. @@ -158,10 +161,8 @@ pub(super) fn broadcast_locked(clients: &mut Vec, frame: TerminalFrame) clients.retain(|client| match client.tx.try_send(frame.clone()) { Ok(()) => true, Err(TrySendError::Full(_)) => { - // At WARN, not DEBUG: this is the one place a client is disconnected - // against its will, and it answers by rebuilding every pane from the - // replay. A person watches that happen, so the default log level has - // to be able to say why it did. + // WARN, not DEBUG: the default log level must be able to say why a + // client was disconnected against its will. tracing::warn!(id = client.id, "viewer: terminal client too slow, dropping"); client.cut_off(); false @@ -199,15 +200,15 @@ pub(super) fn canonical_order(current: &[PaneId], requested: &[PaneId]) -> Vec

, covered: &mut usize, data: &[u8]) -> usize { buf.extend(data.iter().copied()); if buf.len() > limits::MAX_TERMINAL_SCROLLBACK_BYTES { + // Evict history only, never past the `covered` mark: past the cap only + // a fresh snapshot can bring the ring back under it (terminal bytes + // cannot be skipped). The caller weighs *how far* over (see the + // worker's crowded and desperate thresholds in [`hub_run`]). let excess = buf.len() - limits::MAX_TERMINAL_SCROLLBACK_BYTES; let evicted = excess.min(*covered); buf.drain(0..evicted); diff --git a/src/session/terminal/hub_layout.rs b/src/session/terminal/hub_layout.rs index 4ba50b53..efaf5ab4 100644 --- a/src/session/terminal/hub_layout.rs +++ b/src/session/terminal/hub_layout.rs @@ -4,10 +4,65 @@ use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; -use super::hub_helpers::{broadcast_locked, canonical_order}; +use super::hub_helpers::{PendingResize, broadcast_locked, canonical_order}; use crate::backend::{PaneId, PtyBackend, TerminalBackend}; impl TerminalHub { + /// Keep only the newest requested size for this connection and pane. + pub(super) fn queue_resize( + &self, + pane: PaneId, + rows: u16, + cols: u16, + client: u64, + connection: u64, + ) { + // Validate without holding the hub state lock: `connect` takes it before + // ownership, while this path takes the resize queue before ownership. + // Besides dropping an ordinary close race, this bounds the latest-value + // map to live panes even when a client sends arbitrary ids. + if !self.pane_is_live(pane) { + return; + } + let mut pending = self + .pending_resizes + .lock() + .expect("terminal resize queue poisoned"); + // Checked while holding the queue lock so `disconnect` cannot purge and + // then have this request inserted behind it. + if !self.owns_size(connection) { + return; + } + pending.insert( + (connection, pane), + PendingResize { + pane, + rows, + cols, + client, + connection, + }, + ); + } + + pub(super) fn discard_pending_resizes(&self, connection: u64) { + self.pending_resizes + .lock() + .expect("terminal resize queue poisoned") + .retain(|(queued_connection, _), _| *queued_connection != connection); + } + + pub(super) fn take_pending_resizes(&self) -> Vec { + std::mem::take( + &mut *self + .pending_resizes + .lock() + .expect("terminal resize queue poisoned"), + ) + .into_values() + .collect() + } + /// The size a pane's PTY is recorded as having, or `None` once the pane is /// gone. pub(super) fn pane_size(&self, pane: PaneId) -> Option<(u16, u16)> { @@ -22,52 +77,58 @@ impl TerminalHub { /// Resize a live pane's PTY at the sizing owner's request, record the size, /// and tell every client what it is. All under one lock, with the liveness /// check — `connect` reports each pane's size from this record and the - /// client caches it as "already applied"; a client that slipped between the - /// two would be told the old size for a PTY that has the new one, and would - /// then skip the resize that would have corrected it. - /// `modes` is resized with the PTY: the grid a connecting client's screen is - /// read from has to wrap where the child now does (see + /// client caches it as "already applied"; a client that slipped between + /// the two would be told the old size for a PTY that has the new one, and + /// would then skip the resize that would have corrected it. `modes` is + /// resized with the PTY: the grid a connecting client's screen is read + /// from has to wrap where the child now does (see /// [`hub_modes`](super::hub_modes)). pub(super) fn resize_pane( &self, backend: &mut PtyBackend, modes: &mut super::hub_modes::PaneModeTracker, - pane: PaneId, - rows: u16, - cols: u16, - client: u64, + resize: PendingResize, ) { - // Asked before the hub's lock, because the answer is the session's and - // taking the two in the other order would invert the ordering `connect` - // uses (hub lock, then ownership). - let Some(connection) = self.connection_of(client) else { - return; - }; - // Not this client's to set. Dropped rather than refused: a client can - // lose the sizing between laying out a frame and this arriving. - if !self.owns_size(connection) { + let PendingResize { + pane, + rows, + cols, + client, + connection, + } = resize; + let mut state = self.state.lock().expect("terminal state poisoned"); + #[cfg(test)] + self.run_concurrency_test_hook(super::ConcurrencyTestPoint::BeforeResizeValidation); + // The queue may already have handed this value to the worker when its + // connection departs. Validate its original registration and ownership + // together while holding state, in the same state -> ownership order as + // `connect`, so a replacement owner cannot inherit the stale request. + if !self.client_owns_size(&state, client, connection) { return; } - let mut state = self.state.lock().expect("terminal state poisoned"); // An unknown pane is ignored rather than errored: a client racing a // pane exit is normal. let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) else { return; }; - if (p.rows, p.cols) == (rows, cols) { - return; + let changed = (p.rows, p.cols) != (rows, cols); + if changed { + if let Err(err) = backend.resize(pane, rows, cols) { + tracing::warn!(%err, pane, rows, cols, "could not resize a session PTY"); + return; + } + modes.resize(pane, rows, cols); + p.rows = rows; + p.cols = cols; } - backend.resize(pane, rows, cols); - modes.resize(pane, rows, cols); - p.rows = rows; - p.cols = cols; // The grid just reflowed, so a snapshot taken before it wraps where the // child no longer does. Refreshed into whichever record the pane is on // — the emulator's active grid is that screen. Skipped when the last // chunk ended mid-sequence (`at_boundary`): a snapshot anchored there // would splice into an open sequence on replay, and a stale-size screen // is the smaller harm — the next output refreshes it. - if modes.at_boundary(pane) + if changed + && modes.at_boundary(pane) && let Some(screen) = modes.snapshot(pane) { if p.modes.alt_screen { @@ -89,15 +150,11 @@ impl TerminalHub { /// Reorder the live panes to match `order` and tell every client the /// result. /// - /// `order` is a full desired sequence of pane ids. It is reconciled - /// against what is actually live so a reorder is robust to races with - /// create/close: unknown ids are dropped and any live pane the request - /// omits (e.g. one another client created in the same beat) is kept, - /// appended in its current order (see [`canonical_order`]). The hub - /// converges on that one canonical order and broadcasts it, so the - /// sender and every other device end up with the same layout. Reordering - /// only restyles the grid — pane ids, scrollback, and the live PTYs are - /// untouched. A no-op reorder sends nothing. + /// `order` is reconciled against what is actually live so a reorder is + /// robust to races with create/close (see [`canonical_order`]). The hub + /// converges on that one canonical order and broadcasts it, so the sender + /// and every other device end up with the same layout. A no-op reorder + /// sends nothing. pub(super) fn reorder_panes(&self, order: Vec) { let mut state = self.state.lock().expect("terminal state poisoned"); let before: Vec = state.panes.iter().map(|p| p.id).collect(); diff --git a/src/session/terminal/hub_modes.rs b/src/session/terminal/hub_modes.rs index 3610920e..9acaad64 100644 --- a/src/session/terminal/hub_modes.rs +++ b/src/session/terminal/hub_modes.rs @@ -1,41 +1,22 @@ //! What state each pane's program has put its terminal into, and what it calls -//! itself. -//! -//! A client that attaches to a pane mid-session is replayed a window of the -//! pane's output, and the bytes that set the pane's modes are almost never in it -//! — a program announces them once, at startup, and the ring has long since -//! evicted that. So the hub follows them here and hands a connecting client the -//! answer directly (see `PaneModes::prelude`). -//! -//! A window title has exactly that shape too, which is why it is followed here -//! rather than left to each client. A program sets it once with an OSC 0/2 that -//! is out of the ring within seconds, so a page that connected later, or -//! reconnected after a stall, had no way to learn it and fell back to a -//! positional label — the pane running an agent read `term 1` for the rest of -//! the session. +//! itself, followed here because a client attaching mid-session is replayed a +//! window of output that almost never contains the bytes that set them — a +//! program announces modes once, at startup, and the ring has long since +//! evicted that. Titles have the same shape, so they are followed here too. //! //! Kept on the worker thread rather than in [`Shared`](super::Shared): a //! `PaneEmulator` holds `Rc`, so it is not `Send` and cannot live behind the -//! state mutex. What crosses the lock is what the worker writes into `PaneState` -//! after each chunk — the flag set, and whenever a pane's record asks for one -//! the serialized screen (see [`PaneModeTracker::snapshot`]). -//! -//! **The grid is read, so resizes have to be followed.** These emulators used to -//! exist only to answer "which modes is this pane in", and their grids were -//! scratch space for the parser; now `snapshot` reads the cells, so a grid at the -//! wrong width would parse this pane's output wrapping where the child does not -//! and hand a connecting client a screen laid out differently from every other -//! client's. `resize` is what keeps it in step, and `hub_layout::resize_pane` is -//! the one place that has to call it. +//! state mutex. The grid is read (for `snapshot`), so resizes have to be +//! followed — `hub_layout::resize_pane` is the one place that must call +//! `resize`. use crate::backend::PaneId; use crate::runtime::emulator::{PaneEmulator, PaneModes}; use std::collections::HashMap; use std::time::Instant; -/// Scrollback for the tracking emulators: none. A snapshot is the screen, not the -/// history behind it — the byte ring in `PaneState` is what carries history, and -/// paying for it twice per pane would buy nothing. +/// Scrollback for the tracking emulators: none. The byte ring in `PaneState` +/// carries history; paying for it twice per pane would buy nothing. const NO_HISTORY: usize = 0; /// What one chunk of a pane's output said about it. @@ -147,10 +128,10 @@ impl PaneModeTracker { } /// Whether this pane's output so far ends with every sequence closed — the - /// gate on anchoring a snapshot into its records. A chunk can end - /// mid-sequence, and a snapshot spliced in there would hand a reattaching - /// client the sequence's tail as ordinary input; the caller defers to the - /// next chunk that ends clean instead (see + /// gate on anchoring a snapshot into its records. A snapshot spliced in + /// mid-sequence would hand a reattaching client the sequence's tail as + /// ordinary input; the caller defers to the next chunk that ends clean + /// instead (see /// [`PaneEmulator::at_boundary`](crate::runtime::emulator::PaneEmulator::at_boundary)). /// A pane with no output yet is trivially at one. pub(super) fn at_boundary(&self, pane: PaneId) -> bool { diff --git a/src/session/terminal/hub_panes.rs b/src/session/terminal/hub_panes.rs index b37a6145..29e4dda1 100644 --- a/src/session/terminal/hub_panes.rs +++ b/src/session/terminal/hub_panes.rs @@ -1,11 +1,9 @@ //! The hub's pane records: adding one, appending to it, dropping it, and the -//! two questions the worker asks about the list before it acts. -//! -//! Every one of these pairs a change to `Shared` with the broadcast that -//! announces it, under a single lock — that pairing is what keeps a client -//! connecting mid-change from seeing a pane twice or not at all (see -//! [`Shared`](super::hub_helpers::Shared)). Split out of `hub_run.rs` so that -//! file is the worker loop and nothing else; the behaviour is unchanged. +//! two questions the worker asks about the list before it acts. Every one of +//! these pairs a change to `Shared` with the broadcast that announces it, under +//! a single lock — that pairing is what keeps a client connecting mid-change +//! from seeing a pane twice or not at all (see +//! [`Shared`](super::hub_helpers::Shared)). use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; @@ -39,9 +37,8 @@ impl TerminalHub { /// a client either sees this pane via `connect` or via this broadcast, never /// both and never neither. /// `client` is whoever asked for the pane, carried so that client alone can - /// treat it as the one it opened. `None` for a pane nobody asked for. - /// `title` is the name the session gives it, which only a configured startup - /// terminal has. + /// treat it as the one it opened. `title` is the name the session gives it, + /// which only a configured startup terminal has. pub(super) fn register_pane( &self, pane: PaneId, @@ -60,13 +57,10 @@ impl TerminalHub { .ok(); let mut state = self.state.lock().expect("terminal state poisoned"); // A pane nobody can see is not a terminal, so whatever was filling the - // panel gives way to the one about to open. - // - // Ahead of the announcement rather than after it, though both go out - // under this one lock. They are two frames, and a client renders between - // them: told about the pane while still zoomed past it, it spends that - // render with the new terminal hidden — and moves the keyboard onto the - // pane filling the panel instead of the one it just asked for. + // panel gives way to the one about to open. Ahead of the announcement: + // they are two frames and a client renders between them — told about + // the pane while still zoomed past it, it spends that render with the + // new terminal hidden and its keyboard on the wrong pane. clear_zoom_locked(&mut state); state.panes.push(PaneState { id: pane, @@ -86,23 +80,22 @@ impl TerminalHub { } /// Record output against the pane and broadcast it — under one lock, so a - /// concurrently connecting client cannot slip a replay between the record and - /// the broadcast and end up with the pane's screen missing this chunk or - /// carrying it twice. + /// concurrently connecting client cannot slip a replay between the record + /// and the broadcast and end up with the chunk missing or doubled. /// - /// Where the output is recorded depends on the mode the chunk leaves the pane - /// in (see [`PaneState`](super::hub_helpers::PaneState)). `screen` is the - /// serialized screen when the caller has one to hand over, which it takes - /// before locking — the emulator it comes from is not `Send`. + /// Where the output is recorded depends on the mode the chunk leaves the + /// pane in (see [`PaneState`](super::hub_helpers::PaneState)). `screen` is + /// the serialized screen when the caller has one to hand over, which it + /// takes before locking — the emulator it comes from is not `Send`. /// /// Returns how many recorded bytes a fresh snapshot would supersede — the - /// uncovered tail on the normal screen (see [`push_scrollback`]), `since` on - /// the alternate one. The worker reads the pane's appetite for a snapshot - /// off this count (crowded past the cap, desperate well past it). + /// uncovered tail on the normal screen (see [`push_scrollback`]), `since` + /// on the alternate one. The worker reads the pane's appetite for a + /// snapshot off this count (crowded past the cap, desperate well past it). /// - /// The clients already attached are not told the new title: they are being - /// handed the very bytes that set it, and each runs the emulator that reads - /// them. What this record is for is the client that is not here yet. + /// Attached clients are not told a new title: they are being handed the + /// very bytes that set it, and each runs the emulator that reads them. + /// This record is for the client that is not here yet. pub(super) fn record_and_broadcast( &self, pane: PaneId, diff --git a/src/session/terminal/hub_plugins.rs b/src/session/terminal/hub_plugins.rs index e6c21e33..e1f54f8a 100644 --- a/src/session/terminal/hub_plugins.rs +++ b/src/session/terminal/hub_plugins.rs @@ -1,10 +1,8 @@ //! The plugin side of a terminal worker: which panes a plugin may see, the -//! hosts watching them, and the slots being held open for a relaunch. -//! -//! Every field here is worker-local. A plugin can drive a pane's keyboard, so -//! none of this is reachable from a connection thread — the only way in is the -//! command queue the worker already drains, and the only way out is -//! [`crate::plugin::Guard`]. +//! hosts watching them, and the slots being held open for a relaunch. Every +//! field here is worker-local — a plugin can drive a pane's keyboard, so the +//! only way in is the command queue the worker already drains, and the only way +//! out is [`crate::plugin::Guard`]. //! //! A pane appears here only two ways: its `[[startup_command]]` named a plugin //! by hand, or a plugin asked for it by quoting the pane's own token and the @@ -25,9 +23,9 @@ use std::time::Duration; pub(super) const PANE_IDLE_THRESHOLD: Duration = Duration::from_secs(10); /// Commands taken from any one plugin per loop iteration. One thread serves -/// every pane in the repository, so a plugin that writes without pause must not -/// be able to hold it. Eight per 8 ms tick is a thousand a second — far past -/// anything a legitimate plugin needs, and bounded. +/// every pane in the repository, so a plugin that writes without pause must +/// not be able to hold it. Eight per 8 ms tick is a thousand a second — far +/// past anything a legitimate plugin needs, and bounded. pub(super) const MAX_COMMANDS_PER_TICK: usize = 8; pub(super) struct Plugins { @@ -66,13 +64,12 @@ impl Plugins { /// could be given. /// /// Both conditions, because a host with no pane to watch is a child process - /// that can never be given anything to do. `watch_on_signal` is the second - /// way to satisfy the first: such a plugin's panes are the ones that will - /// speak to it, so it has to be running *before* any of them does — waiting - /// for an opt-in that will never come would make the switch mean nothing. + /// that can never be given anything to do. `watch_on_signal` satisfies the + /// second: such a plugin has to be running *before* any of its panes speak, + /// or waiting for an opt-in that never comes makes the switch mean nothing. /// A plugin that will not launch is logged and left out: its panes then - /// behave exactly like unwatched ones, so a broken plugin costs the operator - /// a warning rather than a terminal. + /// behave exactly like unwatched ones, so a broken plugin costs the + /// operator a warning rather than a terminal. pub(super) fn start(cwd: &str, configs: &[PluginConfig], startup: &[StartupCommand]) -> Self { let dir = crate::plugin::registry::default_plugins_dir() .inspect_err(|error| { @@ -128,8 +125,7 @@ impl Plugins { /// Hand `pane` to `plugin`, reporting whether it took. /// /// Refused when that plugin has no host: recording an association nothing - /// can act on would put the pane on the relaunch path — its slot kept alive - /// after an exit for a plugin that will never ask — for no benefit. + /// can act on would put the pane on the relaunch path for no benefit. pub(super) fn adopt(&mut self, pane: PaneId, plugin: &str) -> bool { // Recorded either way: what the pane asked for is a fact about the pane, // and a reload that later enables this plugin has no other way to learn diff --git a/src/session/terminal/hub_plugins_slots.rs b/src/session/terminal/hub_plugins_slots.rs index e44dcd5b..f6945abb 100644 --- a/src/session/terminal/hub_plugins_slots.rs +++ b/src/session/terminal/hub_plugins_slots.rs @@ -12,19 +12,18 @@ use std::time::{Duration, Instant}; /// How long an exited pane's slot is kept so a relaunch can still reuse its /// token. /// -/// This is a backstop against a plugin that died or lost interest, so it has to -/// outlast every wait a plugin may legitimately be in the middle of. Providers -/// quote windows in hours *and* in days — a weekly quota is a real case — so a -/// value picked around the five-hour window would silently throw the pane's -/// identity away days before the wait paid off, and the relaunch it was being -/// kept for would fail. Nine days clears the longest window a bundled plugin -/// will wait out (`nightcrow-recovery`'s own clamp is eight days) with slack for -/// a reset that lands late. +/// A backstop against a plugin that died or lost interest, so it has to +/// outlast every wait a plugin may legitimately be in the middle of. +/// Providers quote windows in hours *and* in days — a weekly quota is a real +/// case — so a value picked around the five-hour window would silently throw +/// the pane's identity away days before the wait paid off. Nine days clears +/// the longest window a bundled plugin will wait out +/// (`nightcrow-recovery`'s own clamp is eight days) with slack. /// -/// Holding it that long is cheap on purpose: a token, a generation and a command -/// string. The process, its fds and its threads were let go the moment it exited -/// (see [`PtyBackend::release_process`]), and closing the pane or stopping the -/// session retires the slot immediately either way. +/// Holding it that long is cheap on purpose: a token, a generation and a +/// command string. The process, its fds and its threads were let go the +/// moment it exited (see [`PtyBackend::release_process`]), and closing the +/// pane or stopping the session retires the slot immediately either way. pub(super) const PENDING_RELAUNCH_TTL: Duration = Duration::from_secs(9 * 24 * 60 * 60); /// Where a pane sat and what it looked like, captured before it is removed. diff --git a/src/session/terminal/hub_relaunch.rs b/src/session/terminal/hub_relaunch.rs index c7b0082d..d9fae9b5 100644 --- a/src/session/terminal/hub_relaunch.rs +++ b/src/session/terminal/hub_relaunch.rs @@ -247,13 +247,10 @@ fn log_plugin_line(plugin: &str, level: LogLevel, message: &str) { /// Log a refusal at the level that says whether anyone should look into it. /// /// A plugin decides asynchronously, so being late is ordinary traffic rather -/// than a fault: the pane moved on, is not quiet yet, or was claimed by another -/// plugin first. The rest mean the plugin asked for something it was never -/// allowed — a pane that is not its, an oversized or control-laden payload, a -/// flag the config does not list, a bare shell it wanted to relaunch, or more -/// attempts than the budget allows — and that is worth an operator's attention. -/// Matched exhaustively on purpose, so a new refusal has to be classified rather -/// than defaulting to silence. +/// than a fault. The rest mean the plugin asked for something it was never +/// allowed — that is worth an operator's attention. Matched exhaustively on +/// purpose, so a new refusal has to be classified rather than defaulting to +/// silence. fn log_refusal(plugin: &str, refused: &Refused) { let ordinary = match refused { Refused::UnknownPane { .. } diff --git a/src/session/terminal/hub_reload.rs b/src/session/terminal/hub_reload.rs index dea31188..6079e40f 100644 --- a/src/session/terminal/hub_reload.rs +++ b/src/session/terminal/hub_reload.rs @@ -1,24 +1,18 @@ -//! Re-applying the `[[plugin]]` table on a hub that is already running. -//! -//! A plugin is a child process rather than a pane, so unlike a startup terminal +//! Re-applying the `[[plugin]]` table on a hub that is already running. A +//! plugin is a child process rather than a pane, so unlike a startup terminal //! it can be replaced without costing the session anything a person was using. //! -//! Three rules the diff below is written around. -//! -//! **The opt-ins are this hub's, not the new file's.** A hub creates its startup -//! panes once for its life, so a `[[startup_command]]` added by the edit has no -//! pane here and will not get one. What decides is the list this hub was spawned -//! with ([`TerminalHub::startup_commands`]). -//! -//! **A plugin that is watching something stays.** Removing a pane's opt-in from -//! the file does not remove the pane, and silently un-watching a live agent -//! terminal is worse than keeping a host the file no longer asks for. Turning -//! `enabled` off is the way to say stop; that is honoured. +//! Three rules the diff below is written around: //! -//! **The guard is never rebuilt.** Its relaunch budget is keyed by a pane's -//! token, which is what bounds a plugin that answers every exit with another -//! relaunch. Rebuilding it here would hand out a fresh allowance on every -//! reload, so the ceiling would never be reached. +//! - **The opt-ins are this hub's, not the new file's.** A hub creates its +//! startup panes once for its life, so what decides is the list this hub was +//! spawned with. +//! - **A plugin that is watching something stays.** Silently un-watching a live +//! agent terminal is worse than keeping a host the file no longer asks for; +//! `enabled = false` is the way to say stop. +//! - **The guard is never rebuilt.** Its relaunch budget is keyed by a pane's +//! token; rebuilding it here would hand out a fresh allowance on every reload +//! and the ceiling would never be reached. use super::TerminalHub; use super::hub_helpers::Command; diff --git a/src/session/terminal/hub_reload_hosts.rs b/src/session/terminal/hub_reload_hosts.rs index 0e9f5be9..c331aa33 100644 --- a/src/session/terminal/hub_reload_hosts.rs +++ b/src/session/terminal/hub_reload_hosts.rs @@ -27,16 +27,11 @@ impl Plugins { self.set_watch_on_signal(&cfg.name, cfg.watch_on_signal); self.launched.insert(cfg.name.clone(), cfg.clone()); self.hosts.insert(cfg.name.clone(), host); - // Every pane that opted into this plugin is handed to it, whether - // it was already owned — the child that knew about it is gone — or - // was never adopted because there was no host when it opened. The - // second case is what makes enabling a plugin mid-session useful: - // a pane created while it was off is still the pane its own - // configuration named. - // - // Only panes the hub still has. `titles` is that set, so a pane - // that has since exited is skipped rather than announced to a - // plugin that could do nothing about it. + // Every pane that opted into this plugin is handed to it, + // whether it was already owned — the child that knew about it is + // gone — or was never adopted because there was no host when it + // opened. The second case is what makes enabling a plugin + // mid-session useful. Only panes the hub still has (`titles`). let opted_in: Vec = self .intended .iter() @@ -93,12 +88,9 @@ impl Plugins { self.launched.remove(name); // Every hold this plugin had goes, replacement or not. A hold is a pane // whose process already exited, kept alive only so *that* plugin could - // relaunch it — and the successor is never told about it. It is handed - // back the panes the hub still has (see `start_host`), and an exited one - // is not among them, so its token dies with the child that was given it. - // Left in place the slot would sit out its whole window with nothing that - // could honour it, while every client counted down to a relaunch that was - // never coming. + // relaunch it — the successor is never told about it and cannot honour + // it. Left in place the slot would sit out its whole window while + // every client counted down to a relaunch that was never coming. self.retire_holds_of(backend, name, outcome); if replaced { // The live panes stay this plugin's, and are handed to the successor diff --git a/src/session/terminal/hub_replay.rs b/src/session/terminal/hub_replay.rs index b3eaa50d..7a441f9c 100644 --- a/src/session/terminal/hub_replay.rs +++ b/src/session/terminal/hub_replay.rs @@ -14,23 +14,14 @@ const LEAVE_ALT_SCREEN: &[u8] = b"\x1b[?1049l"; /// Largest payload one replay frame carries. /// -/// **Frame boundaries mean nothing to a client.** It concatenates what arrives -/// into its emulator, whose parser is a state machine that spans writes, so a -/// sequence or a multi-byte character split across two frames is reassembled the -/// same as if it had come in one. Splitting is therefore free. -/// -/// A single frame, on the other hand, does have a ceiling: the daemon socket -/// refuses a payload over [`MAX_FRAME_BYTES`](crate::daemon::frame::MAX_FRAME_BYTES) -/// (4 MiB), and unlike the byte ring an alternate-screen pane's screen grows with -/// its area — a large pane covered in per-cell colour, which a truecolour image -/// renderer produces, reaches several megabytes. Sent whole it ended the attach -/// connection, and again on every reconnect, because the same screen was replayed -/// each time. Nobody transmits a screen as one indivisible message: VS Code's -/// replay is a list of entries, tmux writes to a passed file descriptor, and -/// mosh's datagrams cannot hold a screen at all. -/// -/// 1 MiB stays well under that ceiling while keeping the frame count low enough -/// that a whole replay of the largest panes this hub allows fits in +/// Frame boundaries mean nothing to a client (it concatenates into its +/// emulator, whose parser spans writes), so splitting is free — but a single +/// frame has a ceiling: the daemon socket refuses a payload over +/// [`MAX_FRAME_BYTES`](crate::daemon::frame::MAX_FRAME_BYTES) (4 MiB), and an +/// alternate-screen pane's screen grows with its area, so sending one whole +/// ended the attach connection on every reconnect. 1 MiB stays well under that +/// ceiling while keeping the frame count low enough that a whole replay of the +/// largest panes this hub allows fits in /// [`CLIENT_QUEUE_DEPTH`](super::CLIENT_QUEUE_DEPTH) — which is what makes it /// safe to queue the replay before the client is registered, with nothing else /// writing to that queue. @@ -104,16 +95,17 @@ pub(super) fn replay_pane(tx: &SyncSender, pane: &PaneState) -> b // Ahead of the screen: these are the modes the pane's program set once, at // startup, and no record of them survives in what follows. Without this a // reattaching client is a terminal the program never configured — mouse - // reporting off, arrows in the wrong encoding, paste unbracketed. It leads - // with `1049`, so it is also what puts the client on the buffer the program is - // drawing on before that buffer's contents arrive. + // reporting off, arrows in the wrong encoding, paste unbracketed. It + // leads with `1049`, so it is also what puts the client on the buffer the + // program is drawing on before that buffer's contents arrive. whole &= send_replay(tx, pane.id, &pane.modes.prelude()); let data: Vec = if pane.modes.alt_screen { // The screen, then everything broadcast since it was taken — the same - // bytes every client already attached has seen. (When an entry snapshot - // was deferred, `since` opens with the switch chunk itself, whose - // pre-switch text this client plays on the wrong buffer until the next - // paint covers it — the price of never splicing into an open sequence.) + // bytes every client already attached has seen. (When an entry + // snapshot was deferred, `since` opens with the switch chunk itself, + // whose pre-switch text plays on the wrong buffer until the next + // paint covers it — the price of never splicing into an open + // sequence.) let mut data = Vec::with_capacity(pane.screen.len() + pane.since.len()); data.extend_from_slice(&pane.screen); data.extend(pane.since.iter().copied()); diff --git a/src/session/terminal/hub_run.rs b/src/session/terminal/hub_run.rs index adcb911e..3126c42b 100644 --- a/src/session/terminal/hub_run.rs +++ b/src/session/terminal/hub_run.rs @@ -1,6 +1,6 @@ use super::frame::{ServerMessage, TerminalFrame}; use super::hub_diag::ClearWatch; -use super::hub_helpers::{Command, broadcast_locked}; +use super::hub_helpers::{Command, broadcast_locked, resize_due_before_command}; use super::hub_modes::PaneModeTracker; use super::hub_plugins::Plugins; use super::{DEFAULT_PANE_SIZE, TerminalHub}; @@ -26,7 +26,13 @@ impl TerminalHub { let mut clears = ClearWatch::default(); while !stop.load(Ordering::Acquire) { + let mut commands_since_resize = 0; while let Ok(command) = commands.try_recv() { + if resize_due_before_command(&mut commands_since_resize) { + for resize in self.take_pending_resizes() { + self.resize_pane(&mut backend, &mut modes, resize); + } + } match command { Command::Create { rows, @@ -70,14 +76,6 @@ impl TerminalHub { plugins.user_input(&backend, pane); let _ = backend.send_input(pane, &data); } - Command::Resize { - pane, - rows, - cols, - client, - } => { - self.resize_pane(&mut backend, &mut modes, pane, rows, cols, client); - } Command::Close { pane } if self.pane_is_live(pane) => { // Closed for good, unlike an exit: the slot goes with // the process, so there is nothing left to relaunch. @@ -104,9 +102,16 @@ impl TerminalHub { } } - // Alternate-screen panes whose screen this tick's output has moved on. - // Snapshotted once at the end rather than per chunk: a busy program - // sends many small chunks and serializing a grid for each of them + // Resize is latest-value state, not a byte stream. The interleave + // above also observes it after each command budget so a producer + // that continuously refills the queue cannot starve final geometry. + for resize in self.take_pending_resizes() { + self.resize_pane(&mut backend, &mut modes, resize); + } + + // Alternate-screen panes whose screen this tick's output has moved + // on. Snapshotted once at the end rather than per chunk: a busy + // program sends many small chunks, and serializing a grid per chunk // would be the most expensive thing on this path. let mut restless: Vec = Vec::new(); for event in backend.drain_events() { @@ -127,29 +132,25 @@ impl TerminalHub { // Cut mid-sequence it is filed into `since` instead, and // the tick's restless pass takes the screen once the // stream closes; until then a connecting client replays - // `since` raw, whose pre-switch text lands on the wrong - // buffer for that moment — the paint that follows a - // switch covers it, and the retry replaces it. + // `since` raw, landing pre-switch text on the wrong + // buffer for that moment — the paint that follows covers + // it, and the retry replaces it. let screen = (alt && observed.alt_changed && modes.at_boundary(pane)) .then(|| modes.snapshot(pane)) .flatten(); let owed = self.record_and_broadcast(pane, data, observed, screen); - // Every snapshot below normally waits for a chunk that - // ends with its sequences closed and applied - // (`at_boundary`): the snapshot is spliced into the - // recorded stream on replay, and a seam inside a - // sequence hands a reattaching client its tail as - // ordinary input. A crowded record reports itself again - // with every next chunk, so a deferred snapshot retries - // until the stream is clean — and a desperate one has - // waited a whole extra ring for that, which no real - // sequence spans, so the stream is called broken and the - // records are bounded over a torn seam. Desperation - // overrides the sequence seam only: a grid missing a - // synchronized update's bytes (`screen_current`) must - // never be snapshotted, and needs no override — the - // update ends at the processor's own buffer cap if - // nothing else. + // Snapshots wait for a chunk that ends with its + // sequences closed (`at_boundary`): the snapshot is + // spliced into the recorded stream on replay, and a seam + // inside a sequence hands a reattaching client its tail + // as ordinary input. A crowded record retries with every + // next chunk; a desperate one has waited a whole extra + // ring, which no real sequence spans, so the records are + // bounded over a torn seam. Desperation overrides the + // sequence seam only — a grid missing a synchronized + // update's bytes (`screen_current`) must never be + // snapshotted, and needs no override: the update ends at + // the processor's own buffer cap if nothing else. let crowded = owed > limits::MAX_TERMINAL_SCROLLBACK_BYTES; let desperate = owed > 2 * limits::MAX_TERMINAL_SCROLLBACK_BYTES; let ready = @@ -166,23 +167,20 @@ impl TerminalHub { restless.push(pane); } } else if crowded && ready { - // The ring's uncovered tail has outgrown the cap, and - // it may not be evicted — a fresh snapshot moving the - // mark is the only way back under. Not per tick like - // the alternate screen's: between snapshots the tail - // keeps the replay exact on its own, so this costs a - // serialization once per ring's worth of output. + // Not per tick like the alternate screen's: between + // snapshots the tail keeps the replay exact on its + // own, so this costs a serialization once per + // ring's worth of output. if let Some(screen) = modes.snapshot(pane) { self.store_normal_screen(pane, screen); } } } - // Destroyed as well as forgotten. `PtyBackend` leaves pane - // removal to its caller (see its `drain_events`), so a pane - // that ended on its own — the user typed `exit`, or the - // command finished — would keep its entry, its PTY master, - // and its child handle for the hub's whole life. The cap - // counts live panes, not those, so open-and-exit in a loop + // Destroyed as well as forgotten: `PtyBackend` leaves pane + // removal to its caller, so a pane that ended on its own + // would otherwise keep its entry, its PTY master, and its + // child handle for the hub's whole life. The cap counts + // live panes, not those, so open-and-exit in a loop // accumulated descriptors with nothing to stop it. BackendEvent::Exited { pane } => { modes.forget(pane); @@ -205,11 +203,11 @@ impl TerminalHub { } } - // A program killed inside a synchronized update never closes it, and - // the pane it leaves behind never produces enough to close it at the - // processor's buffer cap either. Ended here on the clock, so a grid - // no byte will ever release stops holding the pane's modes and every - // snapshot taken from it. + // A program killed inside a synchronized update never closes it, + // and the pane it leaves behind never produces enough to close it + // at the processor's buffer cap either. Ended on the clock, so a + // grid no byte will ever release stops holding the pane's modes + // and every snapshot taken from it. for (pane, observed) in modes.settle_sync(Instant::now()) { let alt = observed.modes.alt_screen; self.store_settled(pane, observed); @@ -251,8 +249,8 @@ impl TerminalHub { } // Ahead of the panes: a plugin child is not one of `PtyBackend`'s panes, - // so this is the only place it is ever reaped, and telling it to stop - // before its panes disappear beneath it is the courteous order. + // so this is the only place it is ever reaped, and it must be told to + // stop before its panes disappear beneath it. plugins.shutdown(); let ids: Vec = self @@ -268,15 +266,14 @@ impl TerminalHub { } // Drop the pane records too: the hub struct can outlive its worker // behind an `Arc`, and a late `connect` must not replay these now-dead - // terminals. The zoom goes with them — it names one of these panes, and - // nothing may be left holding a name for a pane that is gone. + // terminals. The zoom goes with them — it names one of these panes. // - // Announced rather than dropped in silence, because `connect`'s guard - // against replaying them cannot be airtight: a connection that took the - // state lock first read `stop` before it was set, and by the time this - // runs it has already been handed every pane. Telling it here is what - // closes that window from the other side — the guard keeps the common - // case cheap, and this makes the outcome correct either way. + // Announced rather than dropped in silence: `connect`'s guard against + // replaying them cannot be airtight — a connection that took the state + // lock first read `stop` before it was set, and has already been handed + // every pane. This closes that window from the other side; the guard + // keeps the common case cheap and this makes the outcome correct + // either way. let mut state = self.state.lock().expect("terminal state poisoned"); let gone: Vec = state.panes.iter().map(|p| p.id).collect(); state.panes.clear(); diff --git a/src/session/terminal/hub_zoom.rs b/src/session/terminal/hub_zoom.rs index d83e95cc..cf1a8581 100644 --- a/src/session/terminal/hub_zoom.rs +++ b/src/session/terminal/hub_zoom.rs @@ -1,28 +1,16 @@ -//! Which pane fills the terminal panel. +//! Which pane fills the terminal panel — the repository's answer, not each +//! page's, because every page attached to a repository shows the same terminals +//! and per-page state was lost on every reload. An attached TUI is told and +//! ignores it: it has a zoom of its own that follows the TUI's active pane and +//! takes the body from the diff viewer with it. //! -//! **The repository's answer, not each page's.** The same reasoning as the pane -//! order (`hub_layout.rs`): every page attached to a repository shows the same -//! terminals, so "which one is filling the panel" is one question. Keeping it -//! per page instead is what the browser used to do, and it cost the state on -//! every reload — a zoom lived in one `useState` and nothing outside that page -//! had ever heard of it. -//! -//! **An attached TUI is told and ignores it** (`backend/hub.rs`). It has a zoom -//! of its own that answers a different question: it follows the TUI's active -//! pane and takes the body from the diff viewer with it. The panes are shared -//! between the two; what fills a screen is that screen's. -//! -//! **In the hub, and not on disk.** A zoom names a pane, and a pane is a child -//! process of this daemon: restarting it destroys the panes, so there is nothing -//! left for a stored zoom to point at. The panel-level maximize (files vs -//! terminal, `prefs/maximized.rs`) *is* stored, and the difference is exactly -//! this — what it names outlives the process. So a zoom survives a page reload -//! and a TUI restart, which is every case there is a pane to come back to. -//! -//! **A pane appearing or leaving ends it**, which is why the two functions here -//! are called from under the same lock that changes the pane list. A zoom that -//! outlived its pane would leave every client rendering an empty panel, and one -//! that survived a `create` would hide the terminal somebody just asked for. +//! Kept in the hub, not on disk: a zoom names a pane, a pane is a child process +//! of this daemon, so a restart destroys what a stored zoom would point at +//! (the panel-level maximize in `prefs/maximized.rs` *is* stored, and the +//! difference is exactly this). A pane appearing or leaving ends it, which is +//! why the two functions here run under the same lock that changes the pane +//! list — a zoom that outlived its pane leaves every client an empty panel, and +//! one that survived a `create` hides the terminal somebody just asked for. use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; diff --git a/src/session/terminal/mod.rs b/src/session/terminal/mod.rs index 0459f029..6e6a751f 100644 --- a/src/session/terminal/mod.rs +++ b/src/session/terminal/mod.rs @@ -47,12 +47,24 @@ pub use frame::{ClientMessage, PaneSize, TerminalFrame, encode_output}; pub use session::TerminalSession; use crate::session::size_owner::SizeOwnership; -use hub_helpers::{Command, Shared}; +use hub_helpers::{Command, PendingResize, Shared}; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ConcurrencyTestPoint { + BeforeResizeValidation, + DisconnectStateAcquired, + DisconnectStateContended, +} + +#[cfg(test)] +type ConcurrencyTestHook = Arc; + /// Output frames a client may fall behind by before it is dropped. pub(crate) const CLIENT_QUEUE_DEPTH: usize = 256; @@ -63,6 +75,9 @@ const DEFAULT_PANE_SIZE: PaneSize = PaneSize { rows: 24, cols: 80 }; pub struct TerminalHub { pub(super) commands: SyncSender, + /// Latest resize per connection and pane. Separate from `commands` so a + /// full input queue cannot discard the final width of a window drag. + pending_resizes: Mutex>, pub(super) state: Mutex, next_client_id: AtomicU64, stop: Arc, @@ -82,6 +97,8 @@ pub struct TerminalHub { /// Which screen the session's panes are fitted to — shared with every other /// hub because the answer is one per session, not one per repository. ownership: Arc, + #[cfg(test)] + concurrency_test_hook: Mutex>, } impl TerminalHub { @@ -100,6 +117,7 @@ impl TerminalHub { let (commands, command_rx) = mpsc::sync_channel::(256); let hub = Arc::new(Self { commands, + pending_resizes: Mutex::new(BTreeMap::new()), state: Mutex::new(Shared { clients: Vec::new(), panes: Vec::new(), @@ -114,6 +132,8 @@ impl TerminalHub { shell, started: AtomicBool::new(false), ownership, + #[cfg(test)] + concurrency_test_hook: Mutex::new(None), }); let worker_hub = Arc::clone(&hub); @@ -157,6 +177,29 @@ impl TerminalHub { .len() } + #[cfg(test)] + pub(super) fn set_concurrency_test_hook( + &self, + hook: impl Fn(ConcurrencyTestPoint) + Send + Sync + 'static, + ) { + *self + .concurrency_test_hook + .lock() + .expect("terminal concurrency test hook poisoned") = Some(Arc::new(hook)); + } + + #[cfg(test)] + pub(super) fn run_concurrency_test_hook(&self, point: ConcurrencyTestPoint) { + let hook = self + .concurrency_test_hook + .lock() + .expect("terminal concurrency test hook poisoned") + .clone(); + if let Some(hook) = hook { + hook(point); + } + } + pub fn stop(&self) { self.stop.store(true, Ordering::Release); let handle = self diff --git a/src/session/terminal/session.rs b/src/session/terminal/session.rs index a9127156..22176d46 100644 --- a/src/session/terminal/session.rs +++ b/src/session/terminal/session.rs @@ -77,12 +77,9 @@ impl TerminalSession { }, ClientMessage::Resize { pane, rows, cols } => { let size = PaneSize { rows, cols }.clamped(); - Command::Resize { - pane, - rows: size.rows, - cols: size.cols, - client: self.id, - } + self.hub + .queue_resize(pane, size.rows, size.cols, self.id, self.connection); + return; } ClientMessage::Close { pane } => Command::Close { pane }, ClientMessage::Reorder { order } => Command::Reorder { order }, diff --git a/src/session/terminal/size_owner.rs b/src/session/terminal/size_owner.rs index be778180..b21356d0 100644 --- a/src/session/terminal/size_owner.rs +++ b/src/session/terminal/size_owner.rs @@ -1,10 +1,7 @@ -//! The hub's end of the session's size ownership. -//! -//! The rules and the state live one level up, in -//! [`crate::session::size_owner`]: which screen the panes are fitted to is -//! the session's answer, because every client shows the same repository. What is -//! left here is the two places a hub touches it — a client asking for the sizing, -//! and the worker letting a departed owner's grace run out. +//! The hub's end of the session's size ownership. The rules and the state live +//! one level up in [`crate::session::size_owner`]; what is left here is the two +//! places a hub touches it — a client asking for the sizing, and the worker +//! letting a departed owner's grace run out. use super::TerminalHub; @@ -32,20 +29,21 @@ impl TerminalHub { self.ownership.owns(connection) } - /// This hub client's ownership registration, or `None` once it has gone. - /// - /// The two ids are separate on purpose (see [`Client::connection`]), and a - /// command carries the hub's — it was queued by a connection thread and the - /// worker reads it a tick later, by which time that connection may be gone. - /// - /// [`Client::connection`]: super::session::Client::connection - pub(super) fn connection_of(&self, client: u64) -> Option { - self.state - .lock() - .expect("terminal state poisoned") + /// Whether a queued request still belongs to a live hub client whose + /// connection owns the sizing. Called with `state` locked so disconnect and + /// ownership transfer cannot split identity validation from authorization; + /// this preserves the lock order used by `connect` (hub state, then + /// session ownership). + pub(super) fn client_owns_size( + &self, + state: &super::hub_helpers::Shared, + client: u64, + connection: u64, + ) -> bool { + state .clients .iter() - .find(|c| c.id == client) - .map(|c| c.connection) + .any(|c| c.id == client && c.connection == connection) + && self.owns_size(connection) } } diff --git a/src/session/terminal/startup.rs b/src/session/terminal/startup.rs index 7059ab5a..8d70e49e 100644 --- a/src/session/terminal/startup.rs +++ b/src/session/terminal/startup.rs @@ -55,10 +55,9 @@ impl TerminalHub { }) .collect(); - // Hold the free cap slots before the command is even queued. Another - // connection's handler thread can enqueue creates between here and the - // worker reaching this batch; the reservation stops them taking slots - // this set claimed. + // Hold the free cap slots before the command is even queued: another + // connection's handler thread can enqueue creates between here and + // the worker reaching this batch. let reserved = { let mut state = self.state.lock().expect("terminal state poisoned"); let free = limits::MAX_PTYS_PER_REPO.saturating_sub(state.panes.len() + state.reserved); diff --git a/src/session/terminal/startup_run.rs b/src/session/terminal/startup_run.rs index fd48fb2d..1393ffb2 100644 --- a/src/session/terminal/startup_run.rs +++ b/src/session/terminal/startup_run.rs @@ -36,23 +36,19 @@ impl TerminalHub { let mut held = reserved; let mut remaining = panes.into_iter().peekable(); while let Some(pane) = remaining.next() { - // Spend this pane's own reservation first, so the - // check below sees the slot it is about to take as - // free rather than as still held for itself. + // Spend this pane's own reservation first, so the cap check below + // sees the slot it is about to take as free rather than as still + // held for itself. The reservation decides who gets a slot, not + // how many exist — a set larger than what was free at claim time + // comes up short here rather than overrunning the ceiling. if held > 0 { self.release_reserved(1); held -= 1; } - // The cap still binds. The reservation decides who - // gets a slot, not how many exist — a set larger - // than what was free at claim time comes up short - // here rather than overrunning the ceiling. if !self.has_free_slot() { - // Name what did not start. The set is spent - // once claimed, so these will not run until - // the hub restarts — the user has to open them - // by hand, and cannot do that without knowing - // which ones they were. + // Name what did not start. The set is spent once claimed, so + // these will not run until the hub restarts, and the user + // cannot open them by hand without knowing which they were. let mut lost = vec![startup_label(&pane)]; lost.extend(remaining.map(|p| startup_label(&p))); self.send_error_to( @@ -62,11 +58,9 @@ impl TerminalHub { break; } match backend.open_pane(pane.size.rows, pane.size.cols, pane.command.as_deref()) { - // Registered as nobody's: the configured - // terminals belong to the session, not to - // whichever client happened to measure them - // first, so they must not pull that client's - // focus onto them. + // Registered as nobody's: the configured terminals belong to + // the session, not to whichever client happened to measure + // them first, so they must not pull that client's focus. Ok(id) => { self.register_pane( id, @@ -76,10 +70,10 @@ impl TerminalHub { pane.title.clone(), ); // Only here, and only from the pane's own configuration: - // this is the single place a pane ever becomes visible to a - // plugin. `adopt` refuses when the named plugin has no live - // host, so a pane whose plugin failed to launch stays an - // ordinary terminal. + // this is the single place a pane ever becomes visible to + // a plugin. `adopt` refuses when the named plugin has no + // live host, so a pane whose plugin failed to launch stays + // an ordinary terminal. if let Some(name) = pane.plugin.as_deref() && plugins.adopt(id, name) { diff --git a/src/session/terminal/tests/backpressure.rs b/src/session/terminal/tests/backpressure.rs index b5309193..1add9a99 100644 --- a/src/session/terminal/tests/backpressure.rs +++ b/src/session/terminal/tests/backpressure.rs @@ -1,8 +1,9 @@ //! What happens to a client that stops draining its queue. -use super::{attach_over_socket, created_pane, next_matching, spawn_hub}; +use super::{attach, attach_over_socket, created_pane, next_matching, spawn_hub}; use crate::session::terminal::CLIENT_QUEUE_DEPTH; use crate::session::terminal::frame::ClientMessage; +use crate::session::terminal::hub_helpers::resize_due_before_command; use std::io::Read; #[test] @@ -92,3 +93,89 @@ fn an_evicted_client_still_releases_the_sizing_when_its_session_ends() { ); hub.stop(); } + +#[test] +fn the_final_resize_survives_a_full_command_queue() { + // Stop the worker so the ordinary command queue remains deterministically + // full. Resize has latest-value semantics and must stay independently + // writable even then; every intermediate drag position may collapse. + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let session = attach(&hub); + // The worker cleared its real panes while stopping; install one record so + // dispatch still exercises the production liveness boundary. + hub.register_pane(7, 24, 80, None, None); + for _ in 0..CLIENT_QUEUE_DEPTH + 8 { + session.dispatch(ClientMessage::Input { + pane: 7, + data: "x".to_string(), + }); + } + + for cols in 81..=120 { + session.dispatch(ClientMessage::Resize { + pane: 7, + rows: 30, + cols, + }); + } + + let pending = hub.take_pending_resizes(); + assert_eq!(pending.len(), 1, "intermediate sizes must be coalesced"); + assert_eq!((pending[0].rows, pending[0].cols), (30, 120)); +} + +#[test] +fn the_sixty_fifth_ready_command_yields_to_a_resize() { + let mut commands_since_resize = 0; + for _ in 0..64 { + assert!(!resize_due_before_command(&mut commands_since_resize)); + } + + assert!( + resize_due_before_command(&mut commands_since_resize), + "a continuously non-empty queue must yield before its next command" + ); +} + +#[test] +fn unknown_panes_do_not_grow_the_resize_queue() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let session = attach(&hub); + + for pane in 1..=1_000 { + session.dispatch(ClientMessage::Resize { + pane, + rows: 30, + cols: 100, + }); + } + + assert!(hub.take_pending_resizes().is_empty()); +} + +#[test] +fn disconnected_connections_leave_no_pending_resizes() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + hub.register_pane(7, 24, 80, None, None); + + for cols in 81..=1_080 { + let session = attach(&hub); + session.dispatch(ClientMessage::Resize { + pane: 7, + rows: 30, + cols, + }); + drop(session); + } + + assert!( + hub.take_pending_resizes().is_empty(), + "reconnect churn must not retain entries for dead connections" + ); +} diff --git a/src/session/terminal/tests/behavior.rs b/src/session/terminal/tests/behavior.rs index 4863c82c..ca38c9f4 100644 --- a/src/session/terminal/tests/behavior.rs +++ b/src/session/terminal/tests/behavior.rs @@ -174,9 +174,9 @@ fn a_replayed_pane_reports_the_size_it_was_last_resized_to() { // has landed. // // Retrying the connection instead would destroy what it waits for. - // Connecting takes the sizing (`window-size latest`), and a resize from a - // client that no longer owns it is dropped rather than queued - // (`hub_run.rs::apply_resize`). So a `connect` that beats the worker to this + // Connecting takes the sizing (`window-size latest`), and a pending resize + // from a client that no longer owns it is discarded by + // `hub_layout.rs::resize_pane`. So a `connect` that beats the worker to this // still-pending resize discards it for good, and no amount of retrying // brings the size the test is waiting for — it just spends the whole // deadline. Normally the worker wins that race, which is what made the @@ -201,6 +201,30 @@ fn a_replayed_pane_reports_the_size_it_was_last_resized_to() { hub.stop(); } +#[test] +fn retrying_an_already_applied_size_is_acknowledged() { + // A client retries when an acknowledgement is late. Even when the first + // request already landed, the retry must receive the current size or it + // remains pending forever. + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + let session = attach(&hub); + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let pane = next_matching(&session, |f| created_pane(f).is_some()) + .and_then(|f| created_pane(&f)) + .expect("no created message"); + + session.dispatch(ClientMessage::Resize { + pane, + rows: 24, + cols: 80, + }); + + let ack = next_matching(&session, |f| resized_size(f).is_some()).and_then(|f| resized_size(&f)); + assert_eq!(ack, Some((24, 80))); + hub.stop(); +} + #[test] fn input_for_an_unknown_pane_is_ignored() { // A client racing a pane exit is normal traffic, not an error worth diff --git a/src/session/terminal/tests/mod.rs b/src/session/terminal/tests/mod.rs index a1c663ab..f84eeae4 100644 --- a/src/session/terminal/tests/mod.rs +++ b/src/session/terminal/tests/mod.rs @@ -19,6 +19,7 @@ mod screen_records; mod screen_replay; mod scrollback_depth; mod size_owner; +mod size_owner_resize_race; mod startup; mod wire; mod zoom; diff --git a/src/session/terminal/tests/size_owner.rs b/src/session/terminal/tests/size_owner.rs index 08d9ad54..479bf2d1 100644 --- a/src/session/terminal/tests/size_owner.rs +++ b/src/session/terminal/tests/size_owner.rs @@ -193,7 +193,8 @@ fn only_the_owner_resizes_the_pty_and_everyone_is_told_the_size() { let second = attach(&hub); assert!(verdict(&second)); - // Both ask, in this order, through the one command queue the hub drains. + // Both ask in this order. Resize has its own latest-value queue, but the + // ownership check still admits only the current owner's value. first.dispatch(ClientMessage::Resize { pane, rows: 40, diff --git a/src/session/terminal/tests/size_owner_resize_race.rs b/src/session/terminal/tests/size_owner_resize_race.rs new file mode 100644 index 00000000..8915fdd7 --- /dev/null +++ b/src/session/terminal/tests/size_owner_resize_race.rs @@ -0,0 +1,65 @@ +use super::{SHELL_TEST_DEADLINE, next_matching, resized_size, spawn_hub}; +use crate::backend::PtyBackend; +use crate::config::ShellConfig; +use crate::session::size_owner::ViewerId; +use crate::session::terminal::ConcurrencyTestPoint; +use crate::session::terminal::frame::ClientMessage; +use crate::session::terminal::hub_modes::PaneModeTracker; +use std::sync::{Arc, Barrier, mpsc}; + +#[test] +fn a_taken_resize_linearizes_before_a_racing_disconnect() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let viewer = ViewerId::Browser("resize-race".to_string()); + let old_owner = hub.connect(viewer.clone(), true, None); + let observer = hub.connect(viewer, false, None); + hub.register_pane(7, 24, 80, None, None); + + old_owner.dispatch(ClientMessage::Resize { + pane: 7, + rows: 24, + cols: 80, + }); + let resize = hub + .take_pending_resizes() + .pop() + .expect("the worker must have taken the old request"); + + let (events_tx, events_rx) = mpsc::channel(); + let release_resize = Arc::new(Barrier::new(2)); + let hook_release = Arc::clone(&release_resize); + hub.set_concurrency_test_hook(move |point| { + let _ = events_tx.send(point); + if point == ConcurrencyTestPoint::BeforeResizeValidation { + hook_release.wait(); + } + }); + + let resizing_hub = Arc::clone(&hub); + let backend_dir = dir.path().to_path_buf(); + let resizing = std::thread::spawn(move || { + let mut backend = PtyBackend::new(&backend_dir, ShellConfig::default()); + let mut modes = PaneModeTracker::default(); + resizing_hub.resize_pane(&mut backend, &mut modes, resize); + }); + assert_eq!( + events_rx.recv_timeout(SHELL_TEST_DEADLINE).unwrap(), + ConcurrencyTestPoint::BeforeResizeValidation + ); + + let disconnecting = std::thread::spawn(move || drop(old_owner)); + assert_eq!( + events_rx.recv_timeout(SHELL_TEST_DEADLINE).unwrap(), + ConcurrencyTestPoint::DisconnectStateContended, + "disconnect must wait behind the resize's registration and ownership check" + ); + release_resize.wait(); + resizing.join().expect("resize thread panicked"); + disconnecting.join().expect("disconnect thread panicked"); + + let applied = next_matching(&observer, |frame| resized_size(frame).is_some()) + .and_then(|frame| resized_size(&frame)); + assert_eq!(applied, Some((24, 80))); +} diff --git a/src/test_util.rs b/src/test_util.rs index b4d23b15..0753c3b6 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -121,7 +121,6 @@ pub fn session_state( /// In-memory `TerminalBackend` for tests: spawns nothing, just records the /// command each `create_pane` was asked to run. -#[derive(Default)] pub struct FakeBackend { next_id: crate::backend::PaneId, pub launched: Vec>, @@ -131,6 +130,35 @@ pub struct FakeBackend { /// test can keep a clone and inject synthetic pane output/exit after the /// backend was boxed into `TerminalState`. pub pending_events: std::rc::Rc>>, + /// Resize calls, in order, for convergence tests. + pub resized: std::rc::Rc>>, + /// Whether resize is immediate (local PTY) or awaits a server event. + pub resize_outcome: crate::backend::ResizeOutcome, + /// Synthetic resize failure for error-path tests. + pub resize_error: bool, +} + +impl Default for FakeBackend { + fn default() -> Self { + Self { + next_id: 0, + launched: Vec::new(), + sent: Vec::new(), + pending_events: Default::default(), + resized: Default::default(), + resize_outcome: crate::backend::ResizeOutcome::Applied, + resize_error: false, + } + } +} + +impl FakeBackend { + pub fn with_resize_outcome(outcome: crate::backend::ResizeOutcome) -> Self { + Self { + resize_outcome: outcome, + ..Default::default() + } + } } impl crate::backend::TerminalBackend for FakeBackend { @@ -193,7 +221,18 @@ impl crate::backend::TerminalBackend for FakeBackend { Ok(()) } - fn resize(&mut self, _id: crate::backend::PaneId, _rows: u16, _cols: u16) {} + fn resize( + &mut self, + id: crate::backend::PaneId, + rows: u16, + cols: u16, + ) -> anyhow::Result { + self.resized.borrow_mut().push((id, rows, cols)); + if self.resize_error { + anyhow::bail!("synthetic resize failure"); + } + Ok(self.resize_outcome) + } fn drain_events(&mut self) -> Vec { std::mem::take(&mut *self.pending_events.borrow_mut()) diff --git a/src/ui/commit_list/mod.rs b/src/ui/commit_list/mod.rs index b9ed1bf0..52162bcc 100644 --- a/src/ui/commit_list/mod.rs +++ b/src/ui/commit_list/mod.rs @@ -10,7 +10,7 @@ use ratatui::{ }; pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { - if app.log_view.drill_down { + if app.log_view().drill_down { render_file_list(frame, app, area, accent); } else { render_commit_list(frame, app, area, accent); @@ -22,7 +22,7 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let border_style = super::focused_border_style(focused, accent); let show_search = - app.log_view.commit_search_active || !app.log_view.commit_search_query.is_empty(); + app.log_view().commit_search_active || !app.log_view().commit_search_query.is_empty(); let (list_area, search_area) = if show_search { let chunks = Layout::default() @@ -36,16 +36,16 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let filtered = app.log_commit_filtered_indices(); let match_count = filtered.len(); - let total_count = app.log_view.commits.len(); + let total_count = app.log_view().commits.len(); - let scroll_x = app.log_view.commit_scroll_x; + let scroll_x = app.log_view().commit_scroll_x; let items: Vec = filtered .iter() .map(|&i| { - let entry = &app.log_view.commits[i]; + let entry = &app.log_view().commits[i]; ListItem::new(row::commit_row( entry, - &app.log_decorations, + app.log_decorations(), list_area.width, scroll_x, accent, @@ -64,14 +64,14 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { format!(" {} Log ({total_count}) ", super::jump_legend(app, '1')) }; - let selected_pos = filtered.iter().position(|&i| i == app.log_view.selected); + let selected_pos = filtered.iter().position(|&i| i == app.log_view().selected); super::render_selectable_list(frame, list_area, title, items, selected_pos, border_style); if let Some(sa) = search_area { super::render_search_bar( frame, - app.log_view.commit_search_query.as_str(), - app.log_view.commit_search_active, + app.log_view().commit_search_query.as_str(), + app.log_view().commit_search_active, sa, accent, ); @@ -82,7 +82,8 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let focused = app.focus == Focus::FileList; let border_style = super::focused_border_style(focused, accent); - let show_search = app.log_view.file_search_active || !app.log_view.file_search_query.is_empty(); + let show_search = + app.log_view().file_search_active || !app.log_view().file_search_query.is_empty(); let (list_area, search_area) = if show_search { let chunks = Layout::default() @@ -96,13 +97,13 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let filtered = app.log_file_filtered_indices(); let match_count = filtered.len(); - let total_count = app.log_view.commit_files.len(); + let total_count = app.log_view().commit_files.len(); - let scroll_x = app.log_view.file_scroll_x; + let scroll_x = app.log_view().file_scroll_x; let items: Vec = filtered .iter() .map(|&i| { - let f = &app.log_view.commit_files[i]; + let f = &app.log_view().commit_files[i]; let path: std::borrow::Cow<'_, str> = match f.display_path() { std::borrow::Cow::Borrowed(_) => { std::borrow::Cow::Borrowed(super::char_offset(&f.path, scroll_x)) @@ -123,9 +124,9 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { .collect(); let commit_summary = app - .log_view + .log_view() .commits - .get(app.log_view.selected) + .get(app.log_view().selected) .map(|e| { format!( " {} {} {} ", @@ -147,25 +148,23 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let selected_pos = filtered .iter() - .position(|&i| i == app.log_view.file_selected); + .position(|&i| i == app.log_view().file_selected); super::render_selectable_list(frame, list_area, title, items, selected_pos, border_style); if let Some(sa) = search_area { super::render_search_bar( frame, - app.log_view.file_search_query.as_str(), - app.log_view.file_search_active, + app.log_view().file_search_query.as_str(), + app.log_view().file_search_active, sa, accent, ); } } -/// Char budget for the drill-down title inside `area`. Reserves two cells -/// for the surrounding border corners. The title is then measured in chars -/// (not display width), matching the trade-off documented on -/// `terminal_tab::truncate_tab_title`: ASCII summaries are the common case -/// and CJK titles render slightly under the visual budget. +/// Char budget for the drill-down title inside `area`, reserving two cells +/// for the border corners. Measured in chars (not display width), matching +/// the trade-off documented on `terminal_tab::truncate_tab_title`. fn title_budget(width: u16) -> usize { (width as usize).saturating_sub(2) } diff --git a/src/ui/commit_list/row.rs b/src/ui/commit_list/row.rs index 4ee210f0..dd268c90 100644 --- a/src/ui/commit_list/row.rs +++ b/src/ui/commit_list/row.rs @@ -15,7 +15,7 @@ const SECS_PER_YEAR: i64 = SECS_PER_DAY * 365; /// Terminal width at which the row switches to absolute time, full author, and /// untruncated ref chips. A width rule rather than the `list_fullscreen` flag: /// a wide monitor has the room outside fullscreen too, and it keeps the -/// decision to one threshold — the same shape as `diff_viewer::MIN_SPLIT_WIDTH`. +/// decision to one threshold. pub(super) const MIN_DETAIL_WIDTH: u16 = 120; const AUTHOR_WIDTH: usize = 10; @@ -128,8 +128,8 @@ pub(super) fn commit_row<'a>( spans.push(Span::styled(format!("{id} "), Style::default().fg(accent))); if wide { - // A timestamp the platform cannot place renders as blanks rather than a - // wrong date, keeping the column aligned. Same contract as `wall_clock`. + // A timestamp the platform cannot place renders as blanks rather than + // a wrong date, keeping the column aligned. let stamp = local_date_time(entry.time).unwrap_or_else(|| " ".repeat(16)); spans.push(Span::styled( format!("{stamp} "), diff --git a/src/ui/diff_pane/cache.rs b/src/ui/diff_pane/cache.rs new file mode 100644 index 00000000..49269ab2 --- /dev/null +++ b/src/ui/diff_pane/cache.rs @@ -0,0 +1,111 @@ +use crate::git::diff::{DiffHunk, LineKind}; + +use super::{DiffPane, SplitRow, flush_split_blocks}; + +impl DiffPane { + /// Borrow the loaded diff without exposing a mutation path that can leave + /// the derived render indexes stale. + pub(crate) fn hunks(&self) -> &[DiffHunk] { + &self.hunks + } + + /// Replace the loaded diff and rebuild every index derived from it at the + /// mutation boundary. View, search query, file overlay, anchor, and scroll + /// are deliberately left untouched; callers decide which of those should + /// reset for a particular load mode. + pub fn set_hunks(&mut self, hunks: Vec) { + self.hunks = hunks; + self.generation = self.generation.wrapping_add(1); + + self.total_lines = 0; + self.max_line_number = 0; + + self.hunks_lines_lower.clear(); + self.hunks_lines_lower.reserve(self.hunks.len()); + self.hunk_starts.clear(); + self.hunk_starts.reserve(self.hunks.len()); + self.syntax_shape.clear(); + self.syntax_shape.reserve(self.hunks.len()); + for hunk in &self.hunks { + self.hunk_starts.push(self.total_lines); + self.total_lines = self + .total_lines + .saturating_add(1usize.saturating_add(hunk.lines.len())); + self.syntax_shape.push( + hunk.file_path + .as_deref() + .map(crate::ui::path_extension) + .map(str::to_owned), + ); + + let mut lines_lower = Vec::with_capacity(hunk.lines.len()); + for line in &hunk.lines { + if let Some(old) = line.old_lineno { + self.max_line_number = self.max_line_number.max(old); + } + if let Some(new) = line.new_lineno { + self.max_line_number = self.max_line_number.max(new); + } + lines_lower.push(line.content.to_lowercase()); + } + self.hunks_lines_lower.push(lines_lower); + } + self.lower_cache_generation = Some(self.generation); + + self.split_rows.clear(); + let mut removed = Vec::new(); + let mut added = Vec::new(); + for (hi, hunk) in self.hunks.iter().enumerate() { + self.split_rows.push(SplitRow::Header(hi)); + for (li, line) in hunk.lines.iter().enumerate() { + match line.kind { + LineKind::Removed => removed.push(li), + LineKind::Added => added.push(li), + LineKind::Context => { + flush_split_blocks(&mut self.split_rows, hi, &mut removed, &mut added); + self.split_rows.push(SplitRow::Body { + left: Some((hi, li)), + right: Some((hi, li)), + }); + } + } + } + flush_split_blocks(&mut self.split_rows, hi, &mut removed, &mut added); + } + + self.line_highlights.clear(); + self.highlight_cache_generation = None; + self.search.matches.clear(); + } + + #[cfg(test)] + pub(crate) fn generation(&self) -> u64 { + self.generation + } + + pub(crate) fn max_line_number(&self) -> u32 { + self.max_line_number + } + + pub(crate) fn hunk_starts(&self) -> &[usize] { + &self.hunk_starts + } + + /// Total flat row count across all hunks (1 header + N body lines each). + pub fn line_count(&self) -> usize { + self.total_lines + } + + /// Largest legal `scroll` value: one less than the total row count, or 0 + /// when there are no rows. + pub fn max_scroll(&self) -> usize { + self.line_count().saturating_sub(1) + } + + /// Borrow the mutation-time side-by-side row layout. Within each hunk, + /// consecutive removed/added lines are paired index-by-index (the shorter + /// run padded with blank cells) and context lines are mirrored. + pub fn split_rows(&self) -> &[SplitRow] { + &self.split_rows + } +} diff --git a/src/ui/diff_pane/highlight.rs b/src/ui/diff_pane/highlight.rs index 5fa4ff2e..55419907 100644 --- a/src/ui/diff_pane/highlight.rs +++ b/src/ui/diff_pane/highlight.rs @@ -1,9 +1,8 @@ /// Syntect theme name used for both the diff and file-view highlight caches. pub const DIFF_THEME: &str = "base16-ocean.dark"; -/// One highlighted segment of a body line: foreground RGB + the text. Cached -/// so per-frame rendering does not re-run the syntect highlighter over the -/// whole document for state recovery. +/// One highlighted segment of a body line: foreground RGB + the text, cached +/// so per-frame rendering does not re-run the syntect highlighter. #[derive(Debug, Clone)] pub struct HighlightSegment { pub rgb: (u8, u8, u8), @@ -11,9 +10,9 @@ pub struct HighlightSegment { } /// Run a single line through the supplied syntect highlighter and convert the -/// result into `HighlightSegment`s. Falls back to a single grey segment on -/// highlighter error. Shared by `DiffPane` and `FileViewState` so both caches -/// build segments identically. +/// result into `HighlightSegment`s, falling back to a single grey segment on +/// error. Shared by `DiffPane` and `FileViewState` so both caches build +/// segments identically. pub(crate) fn highlight_line_segments( hl: &mut syntect::easy::HighlightLines, ss: &syntect::parsing::SyntaxSet, diff --git a/src/ui/diff_pane/mod.rs b/src/ui/diff_pane/mod.rs index 022b1ecf..dc802f99 100644 --- a/src/ui/diff_pane/mod.rs +++ b/src/ui/diff_pane/mod.rs @@ -1,3 +1,4 @@ +mod cache; mod highlight; mod search; mod split; @@ -8,7 +9,7 @@ pub(crate) use highlight::highlight_line_segments; pub use highlight::{DIFF_THEME, HighlightSegment}; pub use search::DiffSearch; pub(crate) use search::nearest_match_index; -pub(crate) use split::{flush_split_blocks, resolve_hunk_syntax}; +pub(crate) use split::{flush_split_blocks, resolve_syntax_extension}; use crate::git::diff::DiffHunk; use crate::ui::file_view::FileViewState; @@ -26,8 +27,8 @@ pub enum DiffPaneView { /// One row of the side-by-side layout. `Header` carries the hunk index whose /// `@@ ... @@` spans the full width; `Body` carries the (hunk, line) /// coordinates on each side, with `None` marking a blank padding cell. -/// Coordinates index into `DiffPane::hunks` (and `line_highlights`) so the -/// renderer reuses the prebuilt highlight cache without re-running syntect. +/// Coordinates index into `DiffPane::hunks` so the renderer reuses the +/// prebuilt highlight cache without re-running syntect. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SplitRow { Header(usize), @@ -41,30 +42,44 @@ pub enum SplitRow { /// and the optional file-content overlay. #[derive(Default)] pub struct DiffPane { - pub hunks: Vec, - /// Lowercased copy of each `DiffLine::content` aligned with `hunks`. - /// Built once per diff load so per-keystroke search does not re-lowercase. + /// Loaded hunks. Replaced only through [`DiffPane::set_hunks`] so all + /// render/search indexes observe one generation boundary. + hunks: Vec, + /// Lowercased copy of each `DiffLine::content` aligned with `hunks`, + /// built once per diff load so per-keystroke search does not + /// re-lowercase. pub(crate) hunks_lines_lower: Vec>, - /// Cached syntect highlight output per body line, same shape as - /// `hunks_lines_lower`. Built once when hunks (or the active syntax) - /// change so the renderer skips the full-document state-recovery pass. + /// Cached syntect highlight output per body line. Built once when hunks + /// (or the active syntax) change so the renderer skips the full-document + /// state-recovery pass. pub line_highlights: Vec>>, - /// Per-hunk syntax name at the time `line_highlights` was built. A commit - /// diff can touch files of different types, each needing its own - /// highlighter state. Empty means the cache is unbuilt or invalidated. - pub cached_hunk_syntax: Vec, - /// Sum of `line.content.len()` across all hunk lines at cache build time. - /// Pairs with the shape check so a same-line-count hunk replacement still - /// invalidates the cache. - pub(crate) cached_content_bytes: usize, + /// Monotonic identity of the currently loaded diff. Derived caches carry + /// this generation rather than inspecting the hunks during rendering. + pub(crate) generation: u64, + /// Flat unified row count, populated together with `hunks`. + pub(crate) total_lines: usize, + /// Absolute unified row offset for each hunk, populated with the flat + /// count so a deep viewport can jump into the owning hunk directly. + pub(crate) hunk_starts: Vec, + /// Largest old/new line number in the loaded diff. + pub(crate) max_line_number: u32, + /// File-extension keys used to resolve one syntax per hunk. The keys are + /// captured at mutation time so a cache hit never walks the hunk list. + pub(crate) syntax_shape: Vec>, + /// Cached side-by-side row coordinates. This is rebuilt at mutation time + /// and borrowed by every split frame. + pub(crate) split_rows: Vec, + /// Generation for the lowercase search cache. + pub(crate) lower_cache_generation: Option, + /// Generation for the syntax-highlight cache. + pub(crate) highlight_cache_generation: Option, pub scroll: usize, pub scroll_x: usize, /// Soft-wrap long lines instead of letting them run off the right edge. - /// /// Mutually exclusive with horizontal scrolling by construction, not by - /// choice: ratatui's `Paragraph` ignores its `scroll.x` once wrapping is on. - /// The split view ignores this entirely — halves that wrap to different - /// heights would stop lining up, which is the whole point of that layout. + /// choice: ratatui's `Paragraph` ignores its `scroll.x` once wrapping is + /// on. The split view ignores this entirely — halves that wrap to + /// different heights would stop lining up. pub wrap: bool, pub search: DiffSearch, pub view: DiffPaneView, diff --git a/src/ui/diff_pane/pane_impl.rs b/src/ui/diff_pane/pane_impl.rs index f9290cfc..2d31da81 100644 --- a/src/ui/diff_pane/pane_impl.rs +++ b/src/ui/diff_pane/pane_impl.rs @@ -1,21 +1,10 @@ use crate::git::diff::LineKind; use crate::ui::diff_pane::{ - DIFF_THEME, DiffPane, DiffPaneView, HighlightSegment, SplitRow, flush_split_blocks, - highlight_line_segments, nearest_match_index, resolve_hunk_syntax, + DIFF_THEME, DiffPane, DiffPaneView, HighlightSegment, highlight_line_segments, + nearest_match_index, resolve_syntax_extension, }; impl DiffPane { - /// Total flat row count across all hunks (1 header + N body lines each). - pub fn line_count(&self) -> usize { - self.hunks.iter().map(|h| 1 + h.lines.len()).sum() - } - - /// Largest legal `scroll` value: one less than the total row count, or 0 - /// when there are no rows. - pub fn max_scroll(&self) -> usize { - self.line_count().saturating_sub(1) - } - pub fn scroll_left(&mut self) { let target = self.scroll_x_target_mut(); *target = target.saturating_sub(4); @@ -36,34 +25,6 @@ impl DiffPane { } } - /// Build the side-by-side row layout from the current hunks. Within each - /// hunk, consecutive removed/added lines are paired index-by-index (the - /// shorter run padded with blank cells), and context lines are mirrored. - /// Cheap to recompute: it only walks line kinds and stores coordinates. - pub fn split_rows(&self) -> Vec { - let mut rows = Vec::new(); - for (hi, hunk) in self.hunks.iter().enumerate() { - rows.push(SplitRow::Header(hi)); - let mut removed: Vec = Vec::new(); - let mut added: Vec = Vec::new(); - for (li, line) in hunk.lines.iter().enumerate() { - match line.kind { - LineKind::Removed => removed.push(li), - LineKind::Added => added.push(li), - LineKind::Context => { - flush_split_blocks(&mut rows, hi, &mut removed, &mut added); - rows.push(SplitRow::Body { - left: Some((hi, li)), - right: Some((hi, li)), - }); - } - } - } - flush_split_blocks(&mut rows, hi, &mut removed, &mut added); - } - rows - } - pub fn start_search(&mut self) { self.search.start(); } @@ -111,8 +72,8 @@ impl DiffPane { /// over precomputed strings. `scroll_to_match=true` jumps the viewport to /// the current cursor's match (after a keystroke); `false` keeps the /// viewport pinned and re-anchors `cursor` to the nearest match (a - /// content-only refresh, e.g. a background snapshot tick while a query is - /// active, so the next `n`/`p` does not jump unexpectedly). + /// content-only refresh, e.g. a background snapshot tick, so the next + /// `n`/`p` does not jump unexpectedly). pub fn recompute_matches(&mut self, scroll_to_match: bool) { self.search.matches.clear(); if self.search.query.is_empty() { @@ -135,9 +96,9 @@ impl DiffPane { q_owned = self.search.query.lower().to_owned(); q = &q_owned; let mut flat_idx = 0usize; - for (hunk, lines_lower) in self.hunks.iter().zip(self.hunks_lines_lower.iter()) { + for lines_lower in &self.hunks_lines_lower { flat_idx += 1; // header line - for line_lower in lines_lower.iter().take(hunk.lines.len()) { + for line_lower in lines_lower { if line_lower.contains(q) { self.search.matches.push(flat_idx); } @@ -182,8 +143,9 @@ impl DiffPane { } } - /// Rebuild the lowercased line cache from scratch and invalidate the - /// highlight cache so the renderer rebuilds it on next frame. + /// Rebuild the lowercased line cache for the current generation. Normal + /// callers should use `set_hunks`; this remains a recovery hook for tests + /// and callers that already hold a populated pane. pub fn rebuild_lower_cache(&mut self) { self.hunks_lines_lower.clear(); self.hunks_lines_lower.reserve(self.hunks.len()); @@ -195,65 +157,41 @@ impl DiffPane { .collect(); self.hunks_lines_lower.push(lines); } - self.line_highlights.clear(); - self.cached_hunk_syntax.clear(); + self.lower_cache_generation = Some(self.generation); } - /// Rebuild the lowercased line cache iff its shape diverges from `hunks`. + /// Rebuild the lowercased line cache iff its generation is stale. pub fn ensure_lower_cache(&mut self) { - let shape_matches = self.hunks_lines_lower.len() == self.hunks.len() - && self - .hunks - .iter() - .zip(self.hunks_lines_lower.iter()) - .all(|(h, ll)| ll.len() == h.lines.len()); - if !shape_matches { + if self.lower_cache_generation != Some(self.generation) { self.rebuild_lower_cache(); } } - /// Ensure `line_highlights` matches the current `hunks`, resolving the - /// syntax separately for each hunk from its `file_path`. A commit diff - /// can touch files of different types — using a single syntax for the - /// whole diff would render everything as the first file's language (or - /// plain text). Rebuilds when the cache shape, content size, or any - /// per-hunk syntax diverges. + /// Ensure `line_highlights` matches the current generation, resolving the + /// syntax separately for each hunk: a commit diff can touch files of + /// different types, and a single syntax would render everything as the + /// first file's language. The generation check is the frame hot path; the + /// full syntax/highlight walk happens only after `set_hunks`. pub fn ensure_highlight_cache( &mut self, ss: &syntect::parsing::SyntaxSet, ts: &syntect::highlighting::ThemeSet, ) { + if self.highlight_cache_generation == Some(self.generation) { + return; + } + let per_hunk_syntax: Vec<&syntect::parsing::SyntaxReference> = self - .hunks + .syntax_shape .iter() - .map(|h| resolve_hunk_syntax(ss, h.file_path.as_deref())) + .map(|extension| resolve_syntax_extension(ss, extension.as_deref())) .collect(); - let resolved_names: Vec = per_hunk_syntax.iter().map(|s| s.name.clone()).collect(); - - let shape_matches = self.line_highlights.len() == self.hunks.len() - && self - .hunks - .iter() - .zip(self.line_highlights.iter()) - .all(|(h, lh)| lh.len() == h.lines.len()); - let content_bytes: usize = self - .hunks - .iter() - .flat_map(|h| h.lines.iter()) - .map(|l| l.content.len()) - .sum(); - if shape_matches - && self.cached_content_bytes == content_bytes - && self.cached_hunk_syntax == resolved_names - { - return; - } use syntect::easy::HighlightLines; let theme = &ts.themes[DIFF_THEME]; - // Reset the highlighter state pair whenever the hunk's syntax - // changes — running a JS hunk through a Rust HighlightLines would - // mis-paint stateful multi-line constructs. + // Reset the highlighter state pair whenever the hunk's syntax changes + // — a JS hunk through a Rust HighlightLines would mis-paint stateful + // multi-line constructs. let mut hl_pair: Option<(HighlightLines<'_>, HighlightLines<'_>)> = None; let mut current_syntax_name = String::new(); @@ -280,7 +218,6 @@ impl DiffPane { out.push(per_hunk); } self.line_highlights = out; - self.cached_hunk_syntax = resolved_names; - self.cached_content_bytes = content_bytes; + self.highlight_cache_generation = Some(self.generation); } } diff --git a/src/ui/diff_pane/search.rs b/src/ui/diff_pane/search.rs index 79c5bbee..e1c1c903 100644 --- a/src/ui/diff_pane/search.rs +++ b/src/ui/diff_pane/search.rs @@ -58,9 +58,8 @@ impl DiffSearch { if self.matches.is_empty() { return None; } - // Defensive clamp: `recompute_matches(false)` re-anchors `cursor` to - // the nearest match, but a stale cursor can otherwise survive here - // through code paths that mutate `matches` without re-anchoring. + // Defensive clamp: a stale cursor can survive here through code paths + // that mutate `matches` without re-anchoring. if self.cursor >= self.matches.len() { self.cursor = 0; } else { diff --git a/src/ui/diff_pane/split.rs b/src/ui/diff_pane/split.rs index 935448c6..8ca4fd0e 100644 --- a/src/ui/diff_pane/split.rs +++ b/src/ui/diff_pane/split.rs @@ -20,15 +20,14 @@ pub(crate) fn flush_split_blocks( added.clear(); } -/// Pick the syntect syntax for a hunk based on its `file_path`'s extension. -/// Falls back to plain text when the path is absent (test fixtures) or the -/// extension is unknown. -pub(crate) fn resolve_hunk_syntax<'a>( +/// Pick the syntect syntax from a mutation-time file-extension key. Falls back +/// to plain text when the path is absent (test fixtures) or the extension is +/// unknown. +pub(crate) fn resolve_syntax_extension<'a>( ss: &'a syntect::parsing::SyntaxSet, - file_path: Option<&str>, + extension: Option<&str>, ) -> &'a syntect::parsing::SyntaxReference { - file_path - .map(crate::ui::path_extension) + extension .and_then(|ext| ss.find_syntax_by_extension(ext)) .unwrap_or_else(|| ss.find_syntax_plain_text()) } diff --git a/src/ui/diff_pane/tests/mod.rs b/src/ui/diff_pane/tests/mod.rs index dcc7a040..cb38a3c1 100644 --- a/src/ui/diff_pane/tests/mod.rs +++ b/src/ui/diff_pane/tests/mod.rs @@ -34,12 +34,10 @@ fn match_hunk(lines: &[&str]) -> DiffHunk { fn recompute_matches_keep_scroll_repins_cursor_near_viewport() { // 1 hunk header + 10 body lines. "foo" matches at body indices 0, 4, 8 // → flat rows 1, 5, 9. - let mut pane = DiffPane { - hunks: vec![match_hunk(&[ - "foo a", "b", "c", "d", "foo e", "f", "g", "h", "foo i", "j", - ])], - ..Default::default() - }; + let mut pane = DiffPane::default(); + pane.set_hunks(vec![match_hunk(&[ + "foo a", "b", "c", "d", "foo e", "f", "g", "h", "foo i", "j", + ])]); pane.search.query.set("foo"); pane.scroll = 6; // user is reading near the middle match (row 5) pane.search.cursor = 0; // stale cursor from before content changed @@ -56,10 +54,8 @@ fn recompute_matches_keep_scroll_repins_cursor_near_viewport() { #[test] fn recompute_matches_scroll_to_match_clamps_and_jumps() { - let mut pane = DiffPane { - hunks: vec![match_hunk(&["foo a", "b", "foo c"])], - ..Default::default() - }; + let mut pane = DiffPane::default(); + pane.set_hunks(vec![match_hunk(&["foo a", "b", "foo c"])]); pane.search.query.set("foo"); pane.scroll = 100; // arbitrary; scroll_to_match should overwrite pane.search.cursor = 99; // stale, should clamp to last match index. @@ -87,21 +83,71 @@ fn kinded_hunk(lines: &[(LineKind, &str)]) -> DiffHunk { } } +#[test] +fn replacing_hunks_updates_generation_metadata_and_split_cache() { + let mut pane = DiffPane::default(); + let before = pane.generation(); + pane.set_hunks(vec![DiffHunk { + header: "@@ -120 +220 @@".to_string(), + lines: vec![DiffLine { + kind: LineKind::Context, + content: "fn cached() {}".to_string(), + old_lineno: Some(120), + new_lineno: Some(220), + }], + file_path: Some("src/lib.rs".to_string()), + }]); + + assert_ne!(pane.generation(), before); + assert_eq!(pane.line_count(), 2); + assert_eq!(pane.max_scroll(), 1); + assert_eq!(pane.max_line_number(), 220); + assert_eq!(pane.syntax_shape, vec![Some("rs".to_string())]); + assert_eq!(pane.split_rows().len(), 2); + assert_eq!(pane.hunks_lines_lower[0][0], "fn cached() {}"); +} + +#[test] +fn replacing_hunks_invalidates_highlights_without_resetting_viewport_state() { + let mut pane = DiffPane { + view: DiffPaneView::Split, + scroll: 1, + scroll_x: 8, + ..Default::default() + }; + pane.search.query.set("old"); + pane.set_hunks(vec![kinded_hunk(&[(LineKind::Removed, "old")])]); + let ss = two_face::syntax::extra_newlines(); + let ts = syntect::highlighting::ThemeSet::load_defaults(); + pane.ensure_highlight_cache(&ss, &ts); + assert!(!pane.line_highlights.is_empty()); + + let previous_generation = pane.generation(); + pane.set_hunks(vec![kinded_hunk(&[(LineKind::Added, "new")])]); + + assert_ne!(pane.generation(), previous_generation); + assert!(pane.line_highlights.is_empty()); + assert_eq!(pane.view, DiffPaneView::Split); + assert_eq!(pane.scroll, 1); + assert_eq!(pane.scroll_x, 8); + assert_eq!(pane.search.query.as_str(), "old"); + pane.ensure_highlight_cache(&ss, &ts); + assert!(!pane.line_highlights.is_empty()); +} + #[test] fn split_rows_pairs_changes_and_mirrors_context() { use LineKind::{Added, Context, Removed}; // A typical edit block: one context line, a 2-removed/1-added change, // then a trailing context line. - let pane = DiffPane { - hunks: vec![kinded_hunk(&[ - (Context, "ctx0"), - (Removed, "old a"), - (Removed, "old b"), - (Added, "new a"), - (Context, "ctx1"), - ])], - ..Default::default() - }; + let mut pane = DiffPane::default(); + pane.set_hunks(vec![kinded_hunk(&[ + (Context, "ctx0"), + (Removed, "old a"), + (Removed, "old b"), + (Added, "new a"), + (Context, "ctx1"), + ])]); let rows = pane.split_rows(); assert_eq!( @@ -137,10 +183,8 @@ fn split_rows_pairs_changes_and_mirrors_context() { fn split_rows_pads_added_only_block() { use LineKind::Added; // Pure insertion: every change row has a blank left side. - let pane = DiffPane { - hunks: vec![kinded_hunk(&[(Added, "x"), (Added, "y")])], - ..Default::default() - }; + let mut pane = DiffPane::default(); + pane.set_hunks(vec![kinded_hunk(&[(Added, "x"), (Added, "y")])]); let rows = pane.split_rows(); assert_eq!( rows, diff --git a/src/ui/diff_viewer/file_view.rs b/src/ui/diff_viewer/file_view.rs index f5aa5ff7..82bd03ca 100644 --- a/src/ui/diff_viewer/file_view.rs +++ b/src/ui/diff_viewer/file_view.rs @@ -20,10 +20,7 @@ pub(crate) fn render_file_view( ) { let focused = app.focus == Focus::DiffViewer; let border_style = super::focused_border_style(focused, accent); - // file_view backs a single file by definition, so its key carries the - // path. Status overlays use the workdir path; commit overlays use the - // path inside the commit. - let file_path: &str = match &app.diff.file_view.key { + let file_path: &str = match &app.diff_pane().file_view.key { Some(crate::app::FileViewKey::Status(p)) => p.as_str(), Some(crate::app::FileViewKey::Commit { path, .. }) => path.as_str(), None => "", @@ -33,8 +30,8 @@ pub(crate) fn render_file_view( .find_syntax_by_extension(ext) .unwrap_or_else(|| ss.find_syntax_plain_text()); - let has_search = app.diff.search.has_query(); - let show_search = app.diff.search.is_visible(); + let has_search = app.diff_pane().search.has_query(); + let show_search = app.diff_pane().search.is_visible(); let (content_area, search_area) = if show_search { let chunks = Layout::default() @@ -48,13 +45,13 @@ pub(crate) fn render_file_view( let jump = jump_legend(app, '2'); let title = if has_search { - let count = app.diff.search.matches.len(); + let count = app.diff_pane().search.matches.len(); if count == 0 { format!(" {jump} {file_path} [no matches] ") } else { format!( " {jump} {file_path} [{}/{}] ", - app.diff.search.cursor + 1, + app.diff_pane().search.cursor + 1, count ) } @@ -63,32 +60,33 @@ pub(crate) fn render_file_view( }; let visible_height = (content_area.height as usize).saturating_sub(2); - let current_match = app.diff.search.current_match(); + let current_match = app.diff_pane().search.current_match(); // An error or an empty file has no lines to number, so the gutter column is // not reserved at all — otherwise the message would sit indented under it. let mut gutter_lines: Vec = Vec::new(); let mut gutter_width = 0u16; - let lines: Vec = if let Some(err) = &app.diff.file_view.error { + let lines: Vec = if let Some(err) = &app.diff_pane().file_view.error { vec![Line::from(Span::styled( err.as_str(), Style::default().fg(Color::Red), ))] - } else if app.diff.file_view.content.is_empty() { + } else if app.diff_pane().file_view.content.is_empty() { vec![Line::from(Span::styled( "(empty file)", Style::default().fg(Color::DarkGray), ))] } else { - app.diff.file_view.ensure_highlight_cache(ss, ts, syntax); - let fv = &app.diff.file_view; + app.diff_pane_mut() + .file_view + .ensure_highlight_cache(ss, ts, syntax); + let fv = &app.diff_pane().file_view; let total = fv.line_count(); - // Same floor as the diff gutters, so switching between `v` and the diff - // view does not shift the body's left edge. + // Same floor as the diff gutters, so switching between `v` and the + // diff view does not shift the body's left edge. let digits = super::gutter::digits_for(total); gutter_width = super::gutter::side_gutter_width(digits); - // Belt-and-braces: ensure_highlight_cache keeps line_highlights - // aligned with content.lines().count(), but if that invariant ever - // slips the slice below would panic. Clamp against the cache length. + // Belt-and-braces: if the highlight-cache invariant ever slips, the + // slice below would panic — clamp against the cache length. let max_scroll = total .saturating_sub(1) .min(fv.line_highlights.len().saturating_sub(1)); @@ -105,7 +103,8 @@ pub(crate) fn render_file_view( let line_idx = scroll_start + i; let is_anchor = fv.anchor_line == Some(line_no); let is_current = has_search && current_match == Some(line_idx); - let is_match = has_search && !is_current && app.diff.search.is_match(line_idx); + let is_match = + has_search && !is_current && app.diff_pane().search.is_match(line_idx); let bg = if is_current { Color::Rgb(100, 80, 0) } else if is_match { @@ -115,9 +114,8 @@ pub(crate) fn render_file_view( } else { Color::Reset }; - // The number lives in its own paragraph so horizontal scrolling - // cannot slide it off the left edge, which is what used to - // happen while it shared the body's paragraph. + // The number lives in its own paragraph so horizontal + // scrolling cannot slide it off the left edge. gutter_lines.push(Line::from(Span::styled( super::gutter::side_gutter_text(Some(line_no as u32), digits), Style::default().fg(Color::DarkGray).bg(bg), @@ -148,15 +146,15 @@ pub(crate) fn render_file_view( gutter_width, gutter_lines, lines, - app.diff.file_view.scroll_x.min(u16::MAX as usize) as u16, - app.diff.wrap, + app.diff_pane().file_view.scroll_x.min(u16::MAX as usize) as u16, + app.diff_pane().wrap, ); if let Some(sa) = search_area { super::render_search_bar( frame, - app.diff.search.query.as_str(), - app.diff.search.active, + app.diff_pane().search.query.as_str(), + app.diff_pane().search.active, sa, accent, ); diff --git a/src/ui/diff_viewer/gutter.rs b/src/ui/diff_viewer/gutter.rs index d8527838..9101e504 100644 --- a/src/ui/diff_viewer/gutter.rs +++ b/src/ui/diff_viewer/gutter.rs @@ -1,4 +1,3 @@ -use crate::git::diff::DiffHunk; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -6,8 +5,8 @@ use ratatui::{ widgets::{Paragraph, Wrap}, }; -/// Minimum digits reserved for one line-number column. Keeps the gutter from -/// twitching between a 1-digit and a 2-digit file. +/// Minimum digits reserved for one line-number column, so the gutter does not +/// twitch between a 1-digit and a 2-digit file. const MIN_LINENO_DIGITS: usize = 3; /// One padding space on each side of a number column: it lifts the digits off @@ -17,7 +16,7 @@ const LINENO_PAD: usize = 2; /// Single space separating the old and new columns of the unified gutter. const LINENO_GAP: usize = 1; -/// Digits needed to print `max_lineno`, floored at `MIN_LINENO_DIGITS`. +/// Digits needed to print `max_lineno`, floored at the minimum. pub(crate) fn digits_for(max_lineno: usize) -> usize { let digits = if max_lineno == 0 { 1 @@ -27,24 +26,6 @@ pub(crate) fn digits_for(max_lineno: usize) -> usize { digits.max(MIN_LINENO_DIGITS) } -/// Gutter digit count for a whole loaded diff: the widest line number that -/// appears on either side of any hunk. Derived from the loaded hunks, never -/// from the visible window, so scrolling cannot change the gutter width. -/// -/// Recomputed per frame instead of cached: it is one allocation-free pass over -/// the same lines `ensure_highlight_cache` already walks for its fingerprint. -pub(crate) fn lineno_digits(hunks: &[DiffHunk]) -> usize { - let max = hunks - .iter() - .flat_map(|h| h.lines.iter()) - // `Option::max` picks the larger `Some`; both `None` only on fixtures - // and the synthetic binary hunk, which then fall back to the minimum. - .filter_map(|l| l.old_lineno.max(l.new_lineno)) - .max() - .unwrap_or(0); - digits_for(max as usize) -} - /// Width of the unified gutter, which shows the old and new columns together. pub(crate) fn unified_gutter_width(digits: usize) -> u16 { (2 * digits + LINENO_GAP + LINENO_PAD) as u16 @@ -75,17 +56,13 @@ fn lineno_text(no: Option) -> String { } /// Render a pinned gutter column and a horizontally scrollable body inside -/// `inner` (a `Block`'s inner area — draw the block yourself first). -/// -/// The two are separate `Paragraph`s: `Paragraph::scroll` shifts the whole -/// line, so a gutter span living in the body's paragraph would slide off the -/// left edge. Vertical scroll is instead expressed by *which* lines the caller -/// collected. -/// +/// `inner` (a `Block`'s inner area — draw the block yourself first). They are +/// separate `Paragraph`s because `Paragraph::scroll` shifts the whole line, so +/// a gutter span in the body's paragraph would slide off the left edge; +/// vertical scroll is instead expressed by *which* lines the caller collected. /// With `wrap` set that split is abandoned: a wrapped body line occupies -/// several screen rows while its gutter line still occupies one, which would -/// desynchronise every row below it. The number is folded into the body line -/// instead, where wrapping carries it along. +/// several screen rows while its gutter line still occupies one, so the number +/// is folded into the body line instead, where wrapping carries it along. pub(crate) fn render_gutter_and_body( frame: &mut Frame, inner: Rect, @@ -112,9 +89,8 @@ pub(crate) fn render_gutter_and_body( frame.render_widget(Paragraph::new(body).scroll((0, scroll_x)), cols[1]); } -/// Prepend each gutter line's spans to the body line it belongs to. The two -/// vectors are built in lockstep by the callers, so index `i` pairs row `i`; a -/// body row with no gutter entry simply keeps its own spans. +/// Prepend each gutter line's spans to the body line it belongs to; the two +/// vectors are built in lockstep by the callers, so index `i` pairs row `i`. fn merge_gutter_into_body<'a>(gutter: Vec>, body: Vec>) -> Vec> { let mut gutter = gutter.into_iter(); body.into_iter() diff --git a/src/ui/diff_viewer/mod.rs b/src/ui/diff_viewer/mod.rs index 9f3af1a5..206ea644 100644 --- a/src/ui/diff_viewer/mod.rs +++ b/src/ui/diff_viewer/mod.rs @@ -12,7 +12,7 @@ pub(crate) use split_view::render_split_view; use crate::app::{App, DiffPaneView, Focus, ViewMode}; use crate::git::diff::LineKind; use crate::ui::{focused_border_style, path_extension, render_search_bar}; -use gutter::{lineno_digits, render_gutter_and_body, unified_gutter_text, unified_gutter_width}; +use gutter::{digits_for, render_gutter_and_body, unified_gutter_text, unified_gutter_width}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -26,12 +26,8 @@ use title::unified_title; /// Minimum pane width (columns) for the side-by-side split layout. Below this /// each half is too narrow to read, so `Split` view falls back to the unified -/// diff renderer. -/// -/// Derived: 80 columns used to leave each half ~38 columns of code, and each -/// half now spends `side_gutter_width(MIN_LINENO_DIGITS)` = 5 of them on its -/// line-number gutter. Raising the threshold by both gutters keeps the same -/// readable code width per side rather than silently shrinking it. +/// renderer. Raised from 80 by both gutters to keep the readable code width +/// per side rather than silently shrinking it. const MIN_SPLIT_WIDTH: u16 = 90; pub(crate) fn rgb_to_color(rgb: (u8, u8, u8)) -> Color { @@ -46,22 +42,22 @@ pub fn render( ts: &ThemeSet, accent: ratatui::style::Color, ) { - if app.diff.view == DiffPaneView::File { + if app.diff_pane().view == DiffPaneView::File { render_file_view(frame, app, area, ss, ts, accent); return; } // Render side-by-side only when there is a diff to split and the pane is // wide enough; otherwise fall through to the unified renderer below. - if app.diff.view == DiffPaneView::Split + if app.diff_pane().view == DiffPaneView::Split && area.width >= MIN_SPLIT_WIDTH - && !app.diff.hunks.is_empty() + && !app.diff_pane().hunks().is_empty() { render_split_view(frame, app, area, ss, ts, accent); return; } - let show_search = app.diff.search.is_visible(); + let show_search = app.diff_pane().search.is_visible(); let (diff_area, search_area) = if show_search { let chunks = Layout::default() @@ -76,28 +72,28 @@ pub fn render( let focused = app.focus == Focus::DiffViewer; let border_style = focused_border_style(focused, accent); - // Build the syntect highlight cache once per (hunks × per-hunk syntax) - // so the visible-window walk below stays bounded even on large diffs. - app.diff.ensure_highlight_cache(ss, ts); + // Build the syntect highlight cache once per (hunks × per-hunk syntax) so + // the visible-window walk stays bounded even on large diffs. + app.diff_pane_mut().ensure_highlight_cache(ss, ts); - let current_match = app.diff.search.current_match(); - let has_search = app.diff.search.has_query(); + let current_match = app.diff_pane().search.current_match(); + let has_search = app.diff_pane().search.has_query(); // Total flat row count = (1 hunk header + N body lines) per hunk. - let total_lines = app.diff.line_count(); + let total_lines = app.diff_pane().line_count(); let visible_height = (diff_area.height as usize).saturating_sub(2); - let scroll_start = app.diff.scroll.min(app.diff.max_scroll()); + let scroll_start = app.diff_pane().scroll.min(app.diff_pane().max_scroll()); // Keep the stored cursor in sync with the clamped value so a Split-view // scroll position that overshoots this (narrower) unified fallback layout // is corrected on the frame it falls back. - app.diff.scroll = scroll_start; + app.diff_pane_mut().scroll = scroll_start; let visible_end = scroll_start.saturating_add(visible_height); // Gutter width is a property of the whole loaded diff, not of the visible // window, so the body's left edge stays put while scrolling. With no diff // loaded the pane holds only a placeholder message, which has no line to - // number — reserving the column there would just indent the message. - let digits = lineno_digits(&app.diff.hunks); + // number. + let digits = digits_for(app.diff_pane().max_line_number() as usize); let gutter_width = if total_lines == 0 { 0 } else { @@ -108,14 +104,19 @@ pub fn render( // Collected in lockstep with `lines`: same rows, same order, so the two // paragraphs share one vertical window. let mut gutter_lines: Vec = Vec::with_capacity(visible_height); - let mut flat_idx: usize = 0; - - 'outer: for (hi, hunk) in app.diff.hunks.iter().enumerate() { - if flat_idx >= visible_end { + let hunk_starts = app.diff_pane().hunk_starts(); + let first_hunk = hunk_starts + .partition_point(|&start| start <= scroll_start) + .saturating_sub(1); + + 'outer: for (hi, hunk) in app.diff_pane().hunks().iter().enumerate().skip(first_hunk) { + let hunk_start = hunk_starts[hi]; + if hunk_start >= visible_end { break; } - if flat_idx >= scroll_start && flat_idx < visible_end { + let hunk_offset = scroll_start.saturating_sub(hunk_start); + if hunk_offset == 0 { lines.push(Line::from(Span::styled( hunk.header.as_str(), Style::default().fg(Color::Cyan), @@ -124,19 +125,20 @@ pub fn render( // start its `@@` one column left of the body's left edge. gutter_lines.push(Line::from("")); } - flat_idx += 1; - for (li, diff_line) in hunk.lines.iter().enumerate() { + for (li, diff_line) in hunk + .lines + .iter() + .enumerate() + .skip(hunk_offset.saturating_sub(1)) + { + let flat_idx = hunk_start.saturating_add(1).saturating_add(li); if flat_idx >= visible_end { break 'outer; } - if flat_idx < scroll_start { - flat_idx += 1; - continue; - } let is_current = has_search && current_match == Some(flat_idx); - let is_match = has_search && app.diff.search.is_match(flat_idx); + let is_match = has_search && app.diff_pane().search.is_match(flat_idx); let bg = if is_current { Color::Rgb(100, 80, 0) @@ -161,10 +163,15 @@ pub fn render( Style::default().fg(Color::DarkGray).bg(bg), )]; - // Read from the prebuilt highlight cache. Shape is guaranteed to - // match `hunks` after `ensure_highlight_cache`; treat any - // mismatch as a fallback path that just renders the raw text. - if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { + // Read from the prebuilt highlight cache; the shape is guaranteed + // to match `hunks` after `ensure_highlight_cache`, so a mismatch + // only hits the fallback that renders the raw text. + if let Some(segs) = app + .diff_pane() + .line_highlights + .get(hi) + .and_then(|hh| hh.get(li)) + { for seg in segs { spans.push(Span::styled( seg.text.as_str(), @@ -185,30 +192,31 @@ pub fn render( unified_gutter_text(diff_line.old_lineno, diff_line.new_lineno, digits), Style::default().fg(Color::DarkGray).bg(bg), ))); - flat_idx += 1; } } if lines.is_empty() && total_lines == 0 { - let msg = match app.mode { + let msg = match app.mode() { ViewMode::Log => { - if app.log_view.commits.is_empty() { + if app.log_view().commits.is_empty() { "No commits in repository" } else { "No diff for selected commit" } } ViewMode::Status => { - if app.status_view.files.is_empty() { + if app.status_view().files.is_empty() { "No changes in repository" } else { "No diff for selected file" } } - // Tree mode renders the file overlay, not the unified diff, so - // this message is only reachable if the diff view is forced open - // with no file selected. - ViewMode::Tree => "Select a file to preview", + ViewMode::Tree => { + // Tree mode renders the file overlay, not the unified diff, so + // this message is only reachable when the diff view is forced + // open with no file selected. + "Select a file to preview" + } }; lines.push(Line::from(Span::styled( msg, @@ -230,15 +238,15 @@ pub fn render( gutter_width, gutter_lines, lines, - app.diff.scroll_x.min(u16::MAX as usize) as u16, - app.diff.wrap, + app.diff_pane().scroll_x.min(u16::MAX as usize) as u16, + app.diff_pane().wrap, ); if let Some(sa) = search_area { render_search_bar( frame, - app.diff.search.query.as_str(), - app.diff.search.active, + app.diff_pane().search.query.as_str(), + app.diff_pane().search.active, sa, accent, ); diff --git a/src/ui/diff_viewer/split_view.rs b/src/ui/diff_viewer/split_view.rs index ac3bc863..9b02ce9f 100644 --- a/src/ui/diff_viewer/split_view.rs +++ b/src/ui/diff_viewer/split_view.rs @@ -21,24 +21,23 @@ pub(crate) fn render_split_view( ) { let focused = app.focus == Focus::DiffViewer; let border_style = super::focused_border_style(focused, accent); - app.diff.ensure_highlight_cache(ss, ts); + app.diff_pane_mut().ensure_highlight_cache(ss, ts); - let rows = app.diff.split_rows(); + let split_row_count = app.diff_pane().split_rows().len(); let visible_height = (area.height as usize).saturating_sub(2); - let max_scroll = rows.len().saturating_sub(1); - let scroll_start = app.diff.scroll.min(max_scroll); - // Pin the shared scroll cursor to what this layout can actually show. The + let max_scroll = split_row_count.saturating_sub(1); + let scroll_start = app.diff_pane().scroll.min(max_scroll); + // Pin the shared scroll cursor to what this layout can actually show: the // split layout is shorter than the unified flat-row count (paired changes - // collapse onto one row), and navigation clamps against the unified max — - // writing the clamped value back keeps `k`/pgup responsive immediately - // after bottoming out instead of unwinding phantom rows. - app.diff.scroll = scroll_start; + // collapse onto one row), and navigation clamps against the unified max. + app.diff_pane_mut().scroll = scroll_start; + let rows = app.diff_pane().split_rows(); let scroll_end = scroll_start.saturating_add(visible_height).min(rows.len()); // Each half carries the number of the side it shows: old on the left, new // on the right. Collected in lockstep with the body lines so the two // paragraphs of a half share one vertical window. - let digits = super::gutter::lineno_digits(&app.diff.hunks); + let digits = super::gutter::digits_for(app.diff_pane().max_line_number() as usize); let gutter_width = super::gutter::side_gutter_width(digits); let mut left_lines: Vec = Vec::with_capacity(visible_height); @@ -49,8 +48,8 @@ pub(crate) fn render_split_view( match row { SplitRow::Header(hi) => { let header = app - .diff - .hunks + .diff_pane() + .hunks() .get(*hi) .map(|h| h.header.as_str()) .unwrap_or(""); @@ -88,7 +87,7 @@ pub(crate) fn render_split_view( .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(inner); - let scroll_x = app.diff.scroll_x.min(u16::MAX as usize) as u16; + let scroll_x = app.diff_pane().scroll_x.min(u16::MAX as usize) as u16; super::gutter::render_gutter_and_body( frame, halves[0], @@ -96,8 +95,8 @@ pub(crate) fn render_split_view( left_gutter, left_lines, scroll_x, - // Wrapping is deliberately ignored here: halves that fold to different - // heights stop lining up, and lining up is what this layout is for. + // Wrapping is deliberately ignored here: halves that fold to + // different heights stop lining up, and lining up is the point. false, ); @@ -129,10 +128,8 @@ enum Side { /// Build one side's gutter and body `Line` for a split body row, as /// `(gutter, body)`. `None` (no counterpart line on this side) renders both as /// blank; otherwise the cell is styled by line kind and reuses the prebuilt -/// highlight cache, mirroring the unified renderer's per-line treatment. -/// -/// Both lines come from one lookup so they cannot disagree about which -/// `DiffLine` the row is showing. +/// highlight cache, mirroring the unified renderer. Both lines come from one +/// lookup so they cannot disagree about which `DiffLine` the row is showing. fn split_side_lines<'a>( app: &'a App, cell: Option<(usize, usize)>, @@ -143,7 +140,12 @@ fn split_side_lines<'a>( let Some((hi, li)) = cell else { return blank(); }; - let Some(diff_line) = app.diff.hunks.get(hi).and_then(|h| h.lines.get(li)) else { + let Some(diff_line) = app + .diff_pane() + .hunks() + .get(hi) + .and_then(|h| h.lines.get(li)) + else { return blank(); }; @@ -162,7 +164,12 @@ fn split_side_lines<'a>( prefix, Style::default().fg(Color::DarkGray).bg(bg), )]; - if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { + if let Some(segs) = app + .diff_pane() + .line_highlights + .get(hi) + .and_then(|hh| hh.get(li)) + { for seg in segs { spans.push(Span::styled( seg.text.as_str(), diff --git a/src/ui/diff_viewer/tests/file_view.rs b/src/ui/diff_viewer/tests/file_view.rs index 0ef425a7..b58c428c 100644 --- a/src/ui/diff_viewer/tests/file_view.rs +++ b/src/ui/diff_viewer/tests/file_view.rs @@ -5,10 +5,11 @@ use super::*; #[test] fn file_view_line_numbers_stay_put_when_the_body_scrolls_sideways() { let mut app = app_with_files(vec!["src/lib.rs"]); - app.mode = ViewMode::Status; - app.diff.view = DiffPaneView::File; - app.diff.file_view.key = Some(crate::app::FileViewKey::Status("src/lib.rs".to_string())); - app.diff.file_view.content = + app.git.view.mode = ViewMode::Status; + app.git.view.diff.view = DiffPaneView::File; + app.git.view.diff.file_view.key = + Some(crate::app::FileViewKey::Status("src/lib.rs".to_string())); + app.git.view.diff.file_view.content = "fn first() { let a_long_identifier = 1; }\nfn second() {}\n".to_string(); let unscrolled = drawn_file_view(&mut app, 60, 8, 0); @@ -39,6 +40,6 @@ fn file_view_line_numbers_stay_put_when_the_body_scrolls_sideways() { /// The file view reads its own horizontal offset, not `diff.scroll_x`. fn drawn_file_view(app: &mut App, width: u16, height: u16, scroll_x: usize) -> Vec { - app.diff.file_view.scroll_x = scroll_x; + app.git.view.diff.file_view.scroll_x = scroll_x; drawn(app, width, height, 0) } diff --git a/src/ui/diff_viewer/tests/mod.rs b/src/ui/diff_viewer/tests/mod.rs index c89ca3dc..625a2355 100644 --- a/src/ui/diff_viewer/tests/mod.rs +++ b/src/ui/diff_viewer/tests/mod.rs @@ -9,6 +9,7 @@ use crate::app::tests::app_with_files; use crate::app::{App, DiffPaneView, ViewMode}; use crate::git::diff::{DiffHunk, DiffLine, LineKind}; use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color}; +use std::time::Instant; use syntect::highlighting::ThemeSet; /// A context / removed / added trio, which is the shape that exercises every @@ -42,12 +43,72 @@ fn trio_hunk() -> DiffHunk { fn app_showing(hunk: DiffHunk, view: DiffPaneView) -> App { let mut app = app_with_files(vec!["src/lib.rs"]); - app.mode = ViewMode::Status; - app.diff.hunks = vec![hunk]; - app.diff.view = view; + app.git.view.mode = ViewMode::Status; + app.git.view.diff.set_hunks(vec![hunk]); + app.git.view.diff.view = view; app } +#[test] +#[ignore = "release performance benchmark"] +fn repeated_large_split_render_reuses_mutation_caches() { + let lines: Vec = (1..=20_000) + .map(|line_no| DiffLine { + kind: LineKind::Context, + content: format!("line {line_no}"), + old_lineno: Some(line_no), + new_lineno: Some(line_no), + }) + .collect(); + let mut app = app_showing( + DiffHunk { + header: "@@ -1,20000 +1,20000 @@".to_string(), + lines, + file_path: Some("src/lib.rs".to_string()), + }, + DiffPaneView::Split, + ); + let width = 120; + let height = 12; + let ss = two_face::syntax::extra_newlines(); + let ts = ThemeSet::load_defaults(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("a terminal"); + + terminal + .draw(|frame| { + super::render( + frame, + &mut app, + Rect::new(0, 0, width, height), + &ss, + &ts, + Color::Yellow, + ); + }) + .expect("warmup draw"); + let rows_ptr = app.git.view.diff.split_rows().as_ptr(); + let highlights_ptr = app.git.view.diff.line_highlights.as_ptr(); + + let started = Instant::now(); + for _ in 0..100 { + terminal + .draw(|frame| { + super::render( + frame, + &mut app, + Rect::new(0, 0, width, height), + &ss, + &ts, + Color::Yellow, + ); + }) + .expect("repeat draw"); + } + eprintln!("20k-line split render ×100: {:?}", started.elapsed()); + assert_eq!(app.git.view.diff.split_rows().as_ptr(), rows_ptr); + assert_eq!(app.git.view.diff.line_highlights.as_ptr(), highlights_ptr); +} + /// Screen column of `needle`. `str::find` yields a byte offset, and the pane /// border is a 3-byte `│`, so byte offsets are not columns here. fn col_of(line: &str, needle: &str) -> usize { @@ -69,7 +130,7 @@ fn right_columns(line: &str, n: usize) -> String { /// Render the diff pane on its own and return the screen as lines of text. fn drawn(app: &mut App, width: u16, height: u16, scroll_x: usize) -> Vec { - app.diff.scroll_x = scroll_x; + app.git.view.diff.scroll_x = scroll_x; let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("a terminal"); let ss = two_face::syntax::extra_newlines(); let ts = ThemeSet::load_defaults(); diff --git a/src/ui/diff_viewer/tests/unified.rs b/src/ui/diff_viewer/tests/unified.rs index 3ba41e93..7769474d 100644 --- a/src/ui/diff_viewer/tests/unified.rs +++ b/src/ui/diff_viewer/tests/unified.rs @@ -58,6 +58,23 @@ fn unified_gutter_stays_put_when_the_body_scrolls_sideways() { ); } +#[test] +fn unified_view_jumps_to_the_cached_hunk_offset() { + let mut app = app_showing(trio_hunk(), DiffPaneView::Diff); + let mut second = trio_hunk(); + second.header = "@@ second hunk @@".to_string(); + second.lines[0].content = "later();".to_string(); + app.git.view.diff.set_hunks(vec![trio_hunk(), second]); + // The first hunk occupies its header plus three body rows, so the second + // hunk begins at flat row four. + app.git.view.diff.scroll = 4; + + let screen = drawn(&mut app, 60, 10, 0); + let joined = screen.join("\n"); + assert!(joined.contains("@@ second hunk @@")); + assert!(!joined.contains("keep_me")); +} + #[test] fn a_hunk_header_reserves_the_same_gutter_width_as_the_body() { let mut app = app_showing(trio_hunk(), DiffPaneView::Diff); @@ -91,7 +108,7 @@ fn a_hunk_header_reserves_the_same_gutter_width_as_the_body() { #[test] fn an_empty_diff_reserves_no_gutter_for_its_placeholder() { let mut app = app_with_files(vec![]); - app.mode = ViewMode::Status; + app.git.view.mode = ViewMode::Status; let screen = drawn(&mut app, 60, 10, 0); let msg = screen diff --git a/src/ui/diff_viewer/tests/wrap.rs b/src/ui/diff_viewer/tests/wrap.rs index a1b3f5f3..dd4868b1 100644 --- a/src/ui/diff_viewer/tests/wrap.rs +++ b/src/ui/diff_viewer/tests/wrap.rs @@ -32,7 +32,7 @@ fn wrapping_folds_a_long_line_onto_several_rows() { let mut app = app_showing(long_line_hunk(), DiffPaneView::Diff); let truncated = drawn(&mut app, 40, 10, 0); - app.diff.wrap = true; + app.git.view.diff.wrap = true; let wrapped = drawn(&mut app, 40, 10, 0); assert_eq!( @@ -55,7 +55,7 @@ fn wrapping_folds_a_long_line_onto_several_rows() { #[test] fn wrapping_keeps_the_line_number_on_the_row_the_line_starts_on() { let mut app = app_showing(long_line_hunk(), DiffPaneView::Diff); - app.diff.wrap = true; + app.git.view.diff.wrap = true; let screen = drawn(&mut app, 40, 10, 0); let first = screen @@ -82,7 +82,7 @@ fn the_split_view_ignores_wrapping() { // Halves that fold to different heights would stop lining up, and lining up // is the only reason to be in this layout. let mut app = app_showing(long_line_hunk(), DiffPaneView::Split); - app.diff.wrap = true; + app.git.view.diff.wrap = true; let screen = drawn(&mut app, 120, 10, 0); diff --git a/src/ui/diff_viewer/title.rs b/src/ui/diff_viewer/title.rs index a5dbe553..4f8421c4 100644 --- a/src/ui/diff_viewer/title.rs +++ b/src/ui/diff_viewer/title.rs @@ -5,12 +5,12 @@ use crate::ui::jump_legend; /// mode, the selected path in status/tree mode, with per-mode fallbacks for /// "nothing selected". fn diff_label(app: &App) -> String { - match app.mode { + match app.mode() { ViewMode::Log => { - if app.log_view.diff_title.is_empty() { + if app.log_view().diff_title.is_empty() { "Diff".to_string() } else { - app.log_view.diff_title.clone() + app.log_view().diff_title.clone() } } ViewMode::Status => app @@ -18,7 +18,7 @@ fn diff_label(app: &App) -> String { .map(|f| f.path.clone()) .unwrap_or_else(|| "Diff".to_string()), ViewMode::Tree => app - .tree_view + .tree_view() .selected_path() .unwrap_or_else(|| "File".to_string()), } @@ -29,16 +29,16 @@ fn diff_label(app: &App) -> String { pub(crate) fn unified_title(app: &App) -> String { let jump = jump_legend(app, '2'); let label = diff_label(app); - if !app.diff.search.has_query() { + if !app.diff_pane().search.has_query() { return format!(" {jump} {label} "); } - let count = app.diff.search.matches.len(); + let count = app.diff_pane().search.matches.len(); if count == 0 { format!(" {jump} {label} [no matches] ") } else { format!( " {jump} {label} [{}/{}] ", - app.diff.search.cursor + 1, + app.diff_pane().search.cursor + 1, count ) } diff --git a/src/ui/file_list.rs b/src/ui/file_list.rs index a9c73e0d..7da34ac2 100644 --- a/src/ui/file_list.rs +++ b/src/ui/file_list.rs @@ -18,6 +18,8 @@ enum HotStage { Cool, } +const FRESH_DURATION: Duration = Duration::from_secs(5); + /// Bucket a single mtime against `now` and the user's hot window. The /// "fresh" threshold sits well above typical filesystem mtime granularity /// (1s on FAT/older ext4) so the bold→non-bold transition remains easy to @@ -27,18 +29,53 @@ fn classify_hot(mtime: SystemTime, now: SystemTime, hot_window: Duration) -> Hot let age = now.duration_since(mtime).unwrap_or(Duration::ZERO); if age >= hot_window { HotStage::Cool - } else if age < Duration::from_secs(5) { + } else if age < FRESH_DURATION { HotStage::Fresh } else { HotStage::Warm } } +/// Return the next time this mtime can change the rendered stage. +/// +/// A deadline is needed because the status list is now rendered only when the +/// model is dirty; waiting for another filesystem snapshot would leave the +/// accent/bold fade stale on an otherwise idle repository. +pub(crate) fn next_hot_deadline( + mtime: SystemTime, + now: SystemTime, + hot_window: Duration, +) -> Option { + let age = now.duration_since(mtime).unwrap_or(Duration::ZERO); + let transition = if age < hot_window && age < FRESH_DURATION { + FRESH_DURATION.min(hot_window) + } else if age < hot_window { + hot_window + } else { + return None; + }; + mtime.checked_add(transition) +} + +/// Find the earliest stage transition visible in the active status list. +pub(crate) fn next_hot_deadline_for_app(app: &App, now: SystemTime) -> Option { + if app.mode() != crate::app::ViewMode::Status || !app.agent_indicator_config().enabled { + return None; + } + let hot_window = Duration::from_secs(app.agent_indicator_config().hot_window_secs); + app.filtered_indices() + .iter() + .filter_map(|&idx| app.status_view().files.get(idx)) + .filter_map(|file| app.status_view().hot_table.get(&file.path)) + .filter_map(|mtime| next_hot_deadline(*mtime, now, hot_window)) + .min() +} + pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let focused = app.focus == Focus::FileList; let border_style = super::focused_border_style(focused, accent); - let show_search = app.status_view.search_active || !app.status_view.search_query.is_empty(); + let show_search = app.status_view().search_active || !app.status_view().search_query.is_empty(); let (list_area, search_area) = if show_search { let chunks = Layout::default() @@ -53,20 +90,20 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let filtered_indices = app.filtered_indices(); let match_count = filtered_indices.len(); - let indicator_enabled = app.cfg_agent_indicator.enabled; - let hot_window = Duration::from_secs(app.cfg_agent_indicator.hot_window_secs); + let indicator_enabled = app.agent_indicator_config().enabled; + let hot_window = Duration::from_secs(app.agent_indicator_config().hot_window_secs); let now = SystemTime::now(); let items: Vec = filtered_indices .iter() .map(|&idx| { - let f = &app.status_view.files[idx]; + let f = &app.status_view().files[idx]; let symbol = f.short_code(); let color = super::status_color(f.most_severe()); - let scroll_x = app.status_view.file_scroll_x; - // Borrow `f.path` (which outlives the item list) in the common - // non-rename case so rendering stays allocation-free; only - // renames, whose `old -> new` display string is owned, allocate. + let scroll_x = app.status_view().file_scroll_x; + // Borrow `f.path` in the common non-rename case so rendering stays + // allocation-free; only renames, whose `old -> new` display string + // is owned, allocate. let path: std::borrow::Cow<'_, str> = match f.display_path() { std::borrow::Cow::Borrowed(_) => { std::borrow::Cow::Borrowed(super::char_offset(&f.path, scroll_x)) @@ -77,7 +114,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { }; let stage = if indicator_enabled { - app.status_view + app.status_view() .hot_table .get(&f.path) .map(|m| classify_hot(*m, now, hot_window)) @@ -87,9 +124,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { }; // The status symbol keeps its change-status color across all hot - // stages so the change kind stays readable. Recency is conveyed by - // path styling only — no leading glyph — so transitions between - // stages don't shift the row width. + // stages so the change kind stays readable; recency is conveyed by + // path styling only, so stage transitions don't shift the row. let line = match stage { HotStage::Cool => Line::from(vec![ Span::styled(format!("{symbol} "), Style::default().fg(color)), @@ -116,9 +152,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { " {} Files ({}/{}) ", super::jump_legend(app, '1'), match_count, - app.status_view.files.len() + app.status_view().files.len() ) - } else if app.status_view.files.is_empty() { + } else if app.status_view().files.is_empty() { format!(" {} Files (no changes) ", super::jump_legend(app, '1')) } else { format!(" {} Files ", super::jump_legend(app, '1')) @@ -126,14 +162,14 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let selected_pos = filtered_indices .iter() - .position(|&i| i == app.status_view.selected); + .position(|&i| i == app.status_view().selected); super::render_selectable_list(frame, list_area, title, items, selected_pos, border_style); if let Some(sa) = search_area { super::render_search_bar( frame, - app.status_view.search_query.as_str(), - app.status_view.search_active, + app.status_view().search_query.as_str(), + app.status_view().search_active, sa, accent, ); @@ -174,4 +210,64 @@ mod tests { let on_boundary = SystemTime::UNIX_EPOCH + Duration::from_secs(90); assert_eq!(classify_hot(on_boundary, now, window), HotStage::Cool); } + + #[test] + fn next_hot_deadline_marks_the_fresh_to_warm_boundary() { + let window = Duration::from_secs(15); + let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(100); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(102); + + assert_eq!( + next_hot_deadline(mtime, now, window), + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(105)) + ); + assert_eq!( + classify_hot( + mtime, + SystemTime::UNIX_EPOCH + Duration::from_secs(105), + window + ), + HotStage::Warm + ); + } + + #[test] + fn next_hot_deadline_marks_the_warm_to_cool_boundary() { + let window = Duration::from_secs(15); + let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(100); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(106); + + assert_eq!( + next_hot_deadline(mtime, now, window), + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(115)) + ); + assert_eq!( + classify_hot( + mtime, + SystemTime::UNIX_EPOCH + Duration::from_secs(115), + window + ), + HotStage::Cool + ); + } + + #[test] + fn next_hot_deadline_skips_warm_when_window_ends_before_five_seconds() { + let window = Duration::from_secs(3); + let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(100); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(101); + + assert_eq!( + next_hot_deadline(mtime, now, window), + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(103)) + ); + assert_eq!( + classify_hot( + mtime, + SystemTime::UNIX_EPOCH + Duration::from_secs(103), + window + ), + HotStage::Cool + ); + } } diff --git a/src/ui/file_view.rs b/src/ui/file_view.rs index c74a217c..d7121665 100644 --- a/src/ui/file_view.rs +++ b/src/ui/file_view.rs @@ -19,21 +19,19 @@ pub struct FileViewState { pub scroll_x: usize, pub anchor_line: Option, pub error: Option, - /// Cached syntect highlight output, one entry per `content.lines()` line. - /// Built once per (content, syntax) so per-frame rendering only slices + /// Cached syntect highlight output, one entry per `content.lines()` line, + /// built once per (content, syntax) so per-frame rendering only slices /// the visible window. pub line_highlights: Vec>, /// Syntax name used to build `line_highlights`. `None` = unbuilt or /// invalidated. pub cached_syntax_name: Option, - /// Cached `content.lines().count()` populated on load. Avoids walking the - /// full file on every scroll keystroke (`max_scroll` is called from each - /// j/k/PgUp/PgDn handler). + /// Cached `content.lines().count()` populated on load; `max_scroll` is + /// called from every j/k/PgUp/PgDn keystroke, so walking the file per + /// keystroke is not viable. pub(crate) total_lines: usize, - /// Byte length of `content` at cache build time. Combined with - /// `total_lines` it lets `ensure_highlight_cache` notice in-place content - /// edits that keep the line count constant (line counts alone are too - /// coarse a fingerprint). + /// Byte length of `content` at cache build time. With `total_lines` it + /// detects in-place content edits that keep the line count constant. pub(crate) cached_content_len: usize, /// Lowercased copy of each `content` line. Built on demand by /// `ensure_lower_cache` so per-keystroke file search avoids re-lowercasing. @@ -48,9 +46,7 @@ impl FileViewState { /// Replace the rendered content, keeping `total_lines` and the highlight /// cache in lockstep with `content` so partial assignments at call sites /// can't leave them disagreeing (which would make `max_scroll` lie about - /// the legal scroll range). Also clamps `scroll` and drops any prior - /// error so an in-place reload never lands past the new file length or - /// keeps a "load failed" banner over fresh content. + /// the legal scroll range). pub fn set_content(&mut self, content: String) { self.total_lines = if content.is_empty() { 0 @@ -58,8 +54,6 @@ impl FileViewState { content.lines().count() }; self.content = content; - // Highlights are content-derived: stale entries would index past - // `total_lines` or render the previous file's colors. self.line_highlights.clear(); self.cached_syntax_name = None; self.cached_content_len = 0; @@ -80,9 +74,8 @@ impl FileViewState { self.scroll = self.scroll.saturating_add(n).min(self.max_scroll()); } - /// Ensure `lines_lower` is built for the current `content`. Called by - /// `DiffPane::recompute_matches` in File-view mode so per-keystroke search - /// only pays the lowercase cost once per file load. + /// Ensure `lines_lower` is built for the current `content`, so per- + /// keystroke search pays the lowercase cost once per file load. pub(crate) fn ensure_lower_cache(&mut self) { if self.lines_lower.len() == self.total_lines && !self.content.is_empty() { return; diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 848b876a..582f66b4 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -37,9 +37,9 @@ pub(crate) fn status_color(status: StatusKind) -> Color { } } -/// Space-separated because the leader is a *sequence*, not a chord: `^F1` reads -/// as Ctrl+F1, and that misreading names a real binding — the bare F-keys -/// select project tabs. Matches how the hint bar already writes `^F t`. +/// Space-separated because the leader is a *sequence*, not a chord: `^F1` +/// reads as Ctrl+F1, and that misreading names a real binding — the bare +/// F-keys select project tabs. pub(crate) fn jump_legend(app: &App, digit: char) -> String { format!("{} {}", leader_label_of(app.interaction.leader), digit) } @@ -81,11 +81,16 @@ pub(crate) const CARET_BLINK: Duration = Duration::from_millis(530); /// Whether the caret is in the lit half of its cycle. Driven by our own clock /// because `Modifier::SLOW_BLINK` is widely ignored (Windows conhost among -/// them); the event loop's unconditional 16 ms redraw is the frame clock. +/// them); the event loop observes this phase and repaints only on a change. pub(crate) fn caret_lit(elapsed: Duration) -> bool { (elapsed.as_millis() / (CARET_BLINK.as_millis() / 2)).is_multiple_of(2) } +/// Current caret phase for the event loop's dirty-frame clock. +pub(crate) fn current_caret_lit() -> bool { + caret_lit(blink_phase()) +} + /// One origin for all frames, so every caret blinks in step. fn blink_phase() -> Duration { static ORIGIN: OnceLock = OnceLock::new(); diff --git a/src/ui/hint_bar.rs b/src/ui/hint_bar.rs index 1543927e..c08abe04 100644 --- a/src/ui/hint_bar.rs +++ b/src/ui/hint_bar.rs @@ -21,20 +21,16 @@ pub(crate) enum HintClick { /// Leader follow-ups a ` x` segment can name and still be clicked. const CLICKABLE_LEADER_KEYS: &str = "twflbo"; /// Keys a bare segment can name and still be clicked: the leader follow-ups as -/// they appear on the armed row (`t`, `w`, `s`, `z`, `c`, `o`, `x`, `p`, `u`, -/// `r`), plus the commands the focused panel handles unprefixed (`l`, `b`, `f`, -/// `v`, `/`, `n`). +/// they appear on the armed row, plus the commands the focused panel handles +/// unprefixed. const CLICKABLE_PLAIN_KEYS: &str = "twslbfoxpruvzcn/"; /// The click a hint segment's keyspec resolves to, or `None` for a segment -/// that is not clickable. -/// -/// The rule is the one `docs/keybindings.md` states: command hints dispatch, -/// navigation hints do not, and `q: detach` is held back so detaching stays a -/// deliberate two-key act. The keys are listed rather than derived because -/// nothing in the hint text tells a command apart from a navigation hint — -/// which means a command added to `hint_text` stays silently unclickable until -/// it is listed here. This list has already had that gap. +/// that is not clickable. Command hints dispatch, navigation hints do not, and +/// `q: detach` is held back so detaching stays a deliberate two-key act. The +/// keys are listed rather than derived because nothing in the hint text tells +/// a command apart from a navigation hint — a command added to `hint_text` +/// stays silently unclickable until it is listed here. pub(crate) fn segment_click(keyspec: &str) -> Option { let spec = keyspec.trim(); if spec == "" { @@ -84,9 +80,9 @@ pub(crate) fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec< .and_then(|(keyspec, _)| segment_click(keyspec)) .is_some(); if clickable { - // Invert the whole segment — the entire label is the click target. - // Leading whitespace stays plain so the chip doesn't start with a - // stray block. + // Invert the whole segment — the entire label is the click target + // — but keep leading whitespace plain so the chip doesn't start + // with a stray block. let label_start = rendered.len() - rendered.trim_start().len(); let (lead_ws, label) = rendered.split_at(label_start); if !lead_ws.is_empty() { @@ -107,8 +103,8 @@ pub(crate) fn render_hint_bar<'a>( width: u16, ) -> Paragraph<'a> { if chrome.repo_input.active { - // The input itself sits on the notice row, where the repo header was; - // this row carries the dialog's keys and its reports. + // The input itself sits on the notice row; this row carries the + // dialog's keys and its reports. return Paragraph::new(repo_dialog_hint_line( app.notice.as_ref(), chrome.repo_input, @@ -133,8 +129,7 @@ pub(crate) fn render_hint_bar<'a>( } if app.interaction.awaiting_swap_target { // The swap-target digits follow the same layout-aware mapping as the - // focus jumps: `1-8` while the terminal fills the body, `3-9,0` in - // the split view. + // focus jumps: `1-8` fullscreen, `3-9,0` in the split view. let digits = if app.terminal.fullscreen.fills_body() { "1-8" } else { @@ -174,7 +169,7 @@ pub(crate) fn empty_hint_click_at( x: u16, y: u16, ) -> Option { - // Gated like `hint_click_at`: with capture off the row renders plain, and + // Gated like `hint_click_at`: with capture off the row renders plain, but // a browser mouse event still reaches this path, so a label that does not // advertise itself as clickable must not act like one either. if !mouse_enabled { @@ -197,9 +192,9 @@ pub(crate) fn empty_hint_click_at( let rendered = segment.replace("", leader_label); let width = Span::raw(rendered.as_str()).width() as u16; if x >= cursor && x < cursor + width { - // Same rules as `hint_click_at`: leading whitespace renders plain - // and so is not part of the target, and the key is the text before - // the colon. + // Same rules as `hint_spans`: leading whitespace renders plain and + // so is not part of the target, and the key is the text before the + // colon. let label_start = rendered.len() - rendered.trim_start().len(); let lead_width = Span::raw(&rendered[..label_start]).width() as u16; if x < cursor + lead_width { diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs index 2010dcd7..f47de5fd 100644 --- a/src/ui/hint_text.rs +++ b/src/ui/hint_text.rs @@ -7,8 +7,7 @@ pub(crate) const EMPTY_HINT_ARMED: &str = " o: open project | q: detach | esc: c pub(crate) fn prefix_armed_hint_text(app: &App) -> String { // While the terminal fills the body the digit row addresses panes - // directly (`1-8`); in the split view `1`/`2` focus the list/diff and - // `3-9,0` jump to panes. + // directly (`1-8`); in the split view `1`/`2` focus the list/diff. let digits = if app.terminal.fullscreen.fills_body() { "1-8: pane" } else { @@ -33,16 +32,14 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { } else { "" }; - // Only while a plugin actually has a recovery pending, which is rare — an - // always-present hint for it would spend a scarce row on a key that is - // usually inert. + // Only while a plugin actually has a recovery pending, which is rare. let cancel = if app.can_cancel_recovery() { "c: cancel recovery | " } else { "" }; // The view toggles name their destination from the current mode. - let (log_toggle, tree_toggle) = match app.mode { + let (log_toggle, tree_toggle) = match app.mode() { ViewMode::Log => ("l: status view", "b: tree view"), ViewMode::Status => ("l: log view", "b: tree view"), ViewMode::Tree => ("l: log view", "b: status view"), @@ -55,9 +52,9 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { ) } -/// The hint literal (with `` placeholders) for the current -/// non-modal state. Single source for `render_hint_bar` and `hint_click_at`, -/// so the click hit-test always segments exactly the text on screen. +/// The hint literal (with `` placeholders) for the current non-modal +/// state. Single source for `render_hint_bar` and `hint_click_at`, so the +/// click hit-test always segments exactly the text on screen. pub(crate) fn normal_hint_literal(app: &App) -> &'static str { match app.terminal.fullscreen { // From Grid the next `f` zooms the active pane — but only when Zoom @@ -74,23 +71,23 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { } TerminalFullscreen::Off => {} } - if app.diff.fullscreen { - let hint = if app.diff.view == DiffPaneView::File { + if app.diff_pane().fullscreen { + let hint = if app.diff_pane().view == DiffPaneView::File { // Tree mode's right pane is permanently the file view — `v` // can't leave it, so don't advertise a no-op. - if app.mode == ViewMode::Tree { + if app.mode() == ViewMode::Tree { " f: exit zoom | j/k: scroll | pgup/pgdn: page | w: wrap | q: detach" } else { " f: exit zoom | v: back to diff | j/k: scroll | pgup/pgdn: page | w: wrap | q: detach" } - } else if app.diff.view == DiffPaneView::Split { + } else if app.diff_pane().view == DiffPaneView::Split { // No `w: wrap` here or in the unzoomed split arm: the split view // ignores wrapping (halves folding to different heights would stop // lining up), and a hint for a no-op key would lie. " f: exit zoom | s: unified diff | j/k: scroll | pgup/pgdn: page | q: detach" - } else if app.diff.search.active { + } else if app.diff_pane().search.active { " type to search | enter: confirm | esc: cancel" - } else if !app.diff.search.query.is_empty() { + } else if !app.diff_pane().search.query.is_empty() { " f: exit zoom | n: next match | shift+n: prev match | /: new search | esc: clear" } else if app.can_open_file_view() { " f: exit zoom | j/k: scroll | tab: view | w: wrap | v: view file | s: split | /: search | pgup/pgdn: page | q: detach" @@ -101,8 +98,8 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { return hint; } if app.list_fullscreen { - let hint = match app.mode { - ViewMode::Log if app.log_view.drill_down => { + let hint = match app.mode() { + ViewMode::Log if app.log_view().drill_down => { " f: exit zoom | esc: back to commits | j/k: navigate files | q: detach" } ViewMode::Log => { @@ -120,7 +117,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { if let Focus::Terminal = app.focus { // The `l` toggle names its destination: from Log mode it returns to // the status view, from Status/Tree it enters the log view. - return if app.mode == ViewMode::Log { + return if app.mode() == ViewMode::Log { " : leader | shift+up/dn: scroll | shift+pgup/dn: page scroll | shift+left/right: cycle | t: new pane | w: close pane | f: fullscreen | l: status view | o: open project | q: detach" } else { " : leader | shift+up/dn: scroll | shift+pgup/dn: page scroll | shift+left/right: cycle | t: new pane | w: close pane | f: fullscreen | l: log view | o: open project | q: detach" @@ -128,9 +125,9 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { } match app.focus { Focus::Terminal => unreachable!("Focus::Terminal handled above"), - Focus::FileList => match app.mode { + Focus::FileList => match app.mode() { ViewMode::Log => { - if app.log_view.drill_down { + if app.log_view().drill_down { " esc: back to commits | j/k: navigate files | shift+left/right: cycle | q: detach" } else { " shift+left/right: cycle | j/k: navigate commits | enter: view files | t: new pane | f: fullscreen | l: status view | b: tree view | o: open project | q: detach" @@ -144,35 +141,37 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { } }, Focus::DiffViewer => { - if app.diff.view == DiffPaneView::File && app.diff.search.active { + if app.diff_pane().view == DiffPaneView::File && app.diff_pane().search.active { " type to search | enter: confirm | esc: cancel" - } else if app.diff.view == DiffPaneView::File && !app.diff.search.query.is_empty() { + } else if app.diff_pane().view == DiffPaneView::File + && !app.diff_pane().search.query.is_empty() + { " n: next match | shift+n: prev match | /: new search | esc: clear" - } else if app.diff.view == DiffPaneView::File { + } else if app.diff_pane().view == DiffPaneView::File { // Tree mode's right pane is permanently the file view — `v` // can't leave it, so don't advertise a no-op. - if app.mode == ViewMode::Tree { + if app.mode() == ViewMode::Tree { " j/k: scroll | pgup/pgdn: page | w: wrap | /: search | shift+left/right: cycle | q: detach" } else { " v: back to diff | j/k: scroll | pgup/pgdn: page | w: wrap | /: search | shift+left/right: cycle | q: detach" } - } else if app.diff.view == DiffPaneView::Split { + } else if app.diff_pane().view == DiffPaneView::Split { " s: unified diff | j/k: scroll | pgup/pgdn: page | shift+left/right: cycle | f: zoom | q: detach" - } else if app.diff.search.active { + } else if app.diff_pane().search.active { " type to search | enter: confirm | esc: cancel" - } else if !app.diff.search.query.is_empty() { + } else if !app.diff_pane().search.query.is_empty() { " n: next match | shift+n: prev match | /: new search | esc: clear" } else if app.can_open_file_view() { // The `l` toggle names its destination (Tree mode never reaches // these arms — its right pane is always the file view). - if app.mode == ViewMode::Log { + if app.mode() == ViewMode::Log { " shift+left/right: cycle | j/k: scroll | pgup/pgdn: scroll | tab: view | w: wrap | v: view file | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: detach" } else { " shift+left/right: cycle | j/k: scroll | pgup/pgdn: scroll | tab: view | w: wrap | v: view file | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: detach" } } else { // No file target for `v` — a hint for a no-op key would lie. - if app.mode == ViewMode::Log { + if app.mode() == ViewMode::Log { " shift+left/right: cycle | j/k: scroll | pgup/pgdn: scroll | tab: view | w: wrap | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: detach" } else { " shift+left/right: cycle | j/k: scroll | pgup/pgdn: scroll | tab: view | w: wrap | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: detach" diff --git a/src/ui/hit_test.rs b/src/ui/hit_test.rs index a5dc7429..6fceb403 100644 --- a/src/ui/hit_test.rs +++ b/src/ui/hit_test.rs @@ -32,7 +32,7 @@ pub(crate) fn upper_panel_at( x: u16, y: u16, ) -> Option { - if app.terminal.fullscreen.fills_body() || app.diff.fullscreen || app.list_fullscreen { + if app.terminal.fullscreen.fills_body() || app.diff_pane().fullscreen || app.list_fullscreen { return None; } let main = Layout::default() @@ -80,7 +80,7 @@ pub(crate) fn terminal_widget_area( if app.terminal.fullscreen.fills_body() { return Some(body_area); } - if app.diff.fullscreen || app.list_fullscreen { + if app.diff_pane().fullscreen || app.list_fullscreen { return None; } diff --git a/src/ui/log_view/drill_down.rs b/src/ui/log_view/drill_down.rs new file mode 100644 index 00000000..00a54b54 --- /dev/null +++ b/src/ui/log_view/drill_down.rs @@ -0,0 +1,50 @@ +use crate::git::diff::ChangedFile; +use crate::ui::SearchQuery; +use std::cell::Cell; + +#[derive(Default)] +pub struct CommitDrillDownState { + pub drill_down: bool, + pub commit_files: Vec, + pub file_selected: usize, + pub file_scroll_x: usize, + pub file_search_query: SearchQuery, + pub file_search_active: bool, + pub(crate) commit_files_filter_cache: Vec, + pub(crate) commit_files_width_cache: Cell>, +} + +impl CommitDrillDownState { + pub(crate) fn replace_files(&mut self, files: Vec) { + self.commit_files = files; + self.commit_files_width_cache.set(None); + self.recompute_filter(); + } + + pub(crate) fn reset(&mut self) { + self.drill_down = false; + self.commit_files.clear(); + self.commit_files_width_cache.set(None); + self.file_selected = 0; + self.file_scroll_x = 0; + self.file_search_active = false; + self.file_search_query.clear(); + self.commit_files_filter_cache.clear(); + } + + pub(crate) fn recompute_filter(&mut self) { + self.commit_files_filter_cache.clear(); + if self.file_search_query.is_empty() { + self.commit_files_filter_cache + .extend(0..self.commit_files.len()); + } else { + let query = self.file_search_query.lower(); + self.commit_files_filter_cache.extend( + self.commit_files + .iter() + .enumerate() + .filter_map(|(index, file)| file.search_lower.contains(query).then_some(index)), + ); + } + } +} diff --git a/src/ui/log_view/list.rs b/src/ui/log_view/list.rs new file mode 100644 index 00000000..e3885408 --- /dev/null +++ b/src/ui/log_view/list.rs @@ -0,0 +1,89 @@ +use crate::git::diff::CommitEntry; +use crate::ui::SearchQuery; +use std::cell::Cell; + +#[derive(Default)] +pub struct CommitListState { + pub commits: Vec, + pub selected: usize, + pub commit_scroll_x: usize, + pub commit_search_query: SearchQuery, + pub commit_search_active: bool, + pub(crate) commits_filter_cache: Vec, + pub(crate) commit_width_cache: Cell>, + pub(crate) loaded_count: usize, + pub(crate) pending_fetch: bool, + pub(crate) fully_loaded: bool, + pub(crate) drill: super::CommitDrillDownState, +} + +impl CommitListState { + pub(crate) fn replace(&mut self, commits: Vec) { + self.loaded_count = commits.len(); + self.commits = commits; + self.commit_width_cache.set(None); + self.pending_fetch = false; + self.fully_loaded = false; + self.recompute_filter(); + } + + pub(crate) fn replace_first_page(&mut self, page: Vec, page_size: usize) { + let fully_loaded = page.len() < page_size; + self.replace(page); + self.fully_loaded = fully_loaded; + } + + pub(crate) fn append_page(&mut self, mut page: Vec, page_size: usize) { + let received = page.len(); + self.commits.append(&mut page); + self.loaded_count = self.commits.len(); + if received > 0 { + self.commit_width_cache.set(None); + self.recompute_filter(); + } + self.pending_fetch = false; + self.fully_loaded |= received < page_size; + } + + pub(crate) fn mark_pending(&mut self) -> bool { + if self.pending_fetch { + false + } else { + self.pending_fetch = true; + true + } + } + pub(crate) fn clear_pending(&mut self) { + self.pending_fetch = false; + } + pub(crate) fn recompute_filter(&mut self) { + self.commits_filter_cache.clear(); + if self.commit_search_query.is_empty() { + self.commits_filter_cache.extend(0..self.commits.len()); + } else { + let query = self.commit_search_query.lower(); + self.commits_filter_cache + .extend( + self.commits + .iter() + .enumerate() + .filter_map(|(index, commit)| { + commit.summary_lower.contains(query).then_some(index) + }), + ); + } + } +} + +impl std::ops::Deref for CommitListState { + type Target = super::CommitDrillDownState; + fn deref(&self) -> &Self::Target { + &self.drill + } +} + +impl std::ops::DerefMut for CommitListState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.drill + } +} diff --git a/src/ui/log_view/mod.rs b/src/ui/log_view/mod.rs index 155ded23..ad7fb491 100644 --- a/src/ui/log_view/mod.rs +++ b/src/ui/log_view/mod.rs @@ -1,204 +1,108 @@ -use crate::git::diff::{ChangedFile, CommitEntry}; -use crate::ui::SearchQuery; -use std::cell::Cell; +mod drill_down; +mod list; + +pub use drill_down::CommitDrillDownState; +pub use list::CommitListState; #[derive(Default)] pub struct LogView { - pub commits: Vec, - pub selected: usize, + pub list: CommitListState, pub diff_title: String, - pub drill_down: bool, - pub commit_files: Vec, - pub file_selected: usize, - pub commit_scroll_x: usize, - pub file_scroll_x: usize, - /// Memoized longest-summary char width, keyed by `commits.len()`. - pub(crate) commit_width_cache: Cell>, - /// Memoized longest-path char width for `commit_files`. - pub(crate) commit_files_width_cache: Cell>, - /// Kept in lockstep with `commits.len()` so the worker channel can compare - /// against an expected `skip` and drop stale pages. - pub(crate) loaded_count: usize, - /// Guards against duplicate page-fetch requests. - pub(crate) pending_fetch: bool, - /// The previous fetch returned fewer entries than requested. - pub(crate) fully_loaded: bool, - /// Commit-list incremental search. The cache holds indices into `commits` - /// whose summary matches the lowercased query. Recomputed only when - /// commits or the query change. - pub commit_search_query: SearchQuery, - pub commit_search_active: bool, - pub(crate) commits_filter_cache: Vec, - /// Drill-down file-list incremental search; indices reference - /// `commit_files`. - pub file_search_query: SearchQuery, - pub file_search_active: bool, - pub(crate) commit_files_filter_cache: Vec, } -impl LogView { - /// Replace `commits` and invalidate the summary-width cache. Also resets - /// pagination bookkeeping because `commits` is no longer the result of - /// the previous page sequence. - pub(crate) fn set_commits(&mut self, commits: Vec) { - self.loaded_count = commits.len(); - self.commits = commits; - self.commit_width_cache.set(None); - self.pending_fetch = false; - self.fully_loaded = false; - self.recompute_commit_filter(); +impl std::ops::Deref for LogView { + type Target = CommitListState; + fn deref(&self) -> &Self::Target { + &self.list } +} - /// Install a freshly-fetched first page. - pub(crate) fn set_commits_from_first_page(&mut self, page: Vec, page_size: usize) { - let fully_loaded = page.len() < page_size; - self.set_commits(page); - self.fully_loaded = fully_loaded; +impl std::ops::DerefMut for LogView { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list } +} - /// Append a freshly-fetched page to the tail. - pub(crate) fn append_page(&mut self, mut page: Vec, page_size: usize) { - let received = page.len(); - if received > 0 { - self.commits.append(&mut page); - self.loaded_count = self.commits.len(); - self.commit_width_cache.set(None); - self.recompute_commit_filter(); - } - self.pending_fetch = false; - if received < page_size { - self.fully_loaded = true; - } +impl LogView { + pub(crate) fn set_commits(&mut self, commits: Vec) { + self.list.replace(commits); } - /// Mark a fetch as in flight. Returns `false` if one was already pending. - pub(crate) fn mark_pending(&mut self) -> bool { - if self.pending_fetch { - return false; - } - self.pending_fetch = true; - true + pub(crate) fn set_commits_from_first_page( + &mut self, + commits: Vec, + page_size: usize, + ) { + self.list.replace_first_page(commits, page_size); } - /// Clear the pending flag without appending a page. - pub(crate) fn clear_pending(&mut self) { - self.pending_fetch = false; + pub(crate) fn set_commit_files(&mut self, files: Vec) { + self.list.drill.replace_files(files); } - /// Replace `commit_files` and invalidate the file-width cache. - pub(crate) fn set_commit_files(&mut self, files: Vec) { - self.commit_files = files; - self.commit_files_width_cache.set(None); - self.recompute_file_filter(); + pub(crate) fn recompute_commit_filter(&mut self) { + self.list.recompute_filter(); } - /// Exit drill-down so the upper pane shows the commit list again. pub fn reset_drill_down(&mut self) { - self.drill_down = false; - self.commit_files.clear(); - self.commit_files_width_cache.set(None); - self.file_selected = 0; - self.file_scroll_x = 0; - // Drop file-list search state so a later drill-in does not carry the - // previous commit's query into the new view. - self.file_search_active = false; - self.file_search_query.clear(); - self.commit_files_filter_cache.clear(); - } - - /// Refresh `commits_filter_cache` from `commits` and the current query. - pub(crate) fn recompute_commit_filter(&mut self) { - self.commits_filter_cache.clear(); - if self.commit_search_query.is_empty() { - self.commits_filter_cache.extend(0..self.commits.len()); - return; - } - let q = self.commit_search_query.lower(); - for (i, c) in self.commits.iter().enumerate() { - if c.summary_lower.contains(q) { - self.commits_filter_cache.push(i); - } - } + self.list.drill.reset(); } - /// Refresh `commit_files_filter_cache` from `commit_files` and the - /// current query. - pub(crate) fn recompute_file_filter(&mut self) { - self.commit_files_filter_cache.clear(); - if self.file_search_query.is_empty() { - self.commit_files_filter_cache - .extend(0..self.commit_files.len()); - return; - } - let q = self.file_search_query.lower(); - for (i, f) in self.commit_files.iter().enumerate() { - if f.search_lower.contains(q) { - self.commit_files_filter_cache.push(i); - } - } + #[cfg(test)] + pub(crate) fn enter_drill_down(&mut self) { + self.list.drill.drill_down = true; } pub fn start_commit_search(&mut self) { - self.commit_search_active = true; + self.list.commit_search_active = true; } - - /// Exit the commit-list search bar and clear any active query. pub fn cancel_commit_search(&mut self) { - self.commit_search_active = false; - self.commit_search_query.clear(); - self.recompute_commit_filter(); + self.list.commit_search_active = false; + self.list.commit_search_query.clear(); + self.list.recompute_filter(); } - - /// Hide the commit-list search bar. Returns `true` when the query was - /// empty and the call collapsed to a cancel. pub fn confirm_commit_search(&mut self) -> bool { - if self.commit_search_query.is_empty() { + if self.list.commit_search_query.is_empty() { self.cancel_commit_search(); true } else { - self.commit_search_active = false; + self.list.commit_search_active = false; false } } - pub fn commit_search_push(&mut self, ch: char) { - self.commit_search_query.push(ch); - self.recompute_commit_filter(); + self.list.commit_search_query.push(ch); + self.list.recompute_filter(); } - pub fn commit_search_pop(&mut self) { - self.commit_search_query.pop(); - self.recompute_commit_filter(); + self.list.commit_search_query.pop(); + self.list.recompute_filter(); } pub fn start_file_search(&mut self) { - self.file_search_active = true; + self.list.drill.file_search_active = true; } - pub fn cancel_file_search(&mut self) { - self.file_search_active = false; - self.file_search_query.clear(); - self.recompute_file_filter(); + self.list.drill.file_search_active = false; + self.list.drill.file_search_query.clear(); + self.list.drill.recompute_filter(); } - pub fn confirm_file_search(&mut self) -> bool { - if self.file_search_query.is_empty() { + if self.list.drill.file_search_query.is_empty() { self.cancel_file_search(); true } else { - self.file_search_active = false; + self.list.drill.file_search_active = false; false } } - pub fn file_search_push(&mut self, ch: char) { - self.file_search_query.push(ch); - self.recompute_file_filter(); + self.list.drill.file_search_query.push(ch); + self.list.drill.recompute_filter(); } - pub fn file_search_pop(&mut self) { - self.file_search_query.pop(); - self.recompute_file_filter(); + self.list.drill.file_search_query.pop(); + self.list.drill.recompute_filter(); } } diff --git a/src/ui/log_view/tests.rs b/src/ui/log_view/tests.rs index 6dabb7bf..86946a39 100644 --- a/src/ui/log_view/tests.rs +++ b/src/ui/log_view/tests.rs @@ -171,10 +171,8 @@ fn set_commit_files_seeds_filter_cache_under_active_query() { #[test] fn reset_drill_down_clears_file_search_state() { - let mut lv = LogView { - drill_down: true, - ..Default::default() - }; + let mut lv = LogView::default(); + lv.enter_drill_down(); lv.set_commit_files(vec![ChangedFile::unstaged_only( "readme.md".into(), crate::git::diff::StatusKind::Modified, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 89a8238b..d1382c4c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -27,9 +27,10 @@ mod tests; mod wall_clock; pub(crate) use chrome::{Chrome, chrome_rows, main_content_constraints}; +pub(crate) use file_list::next_hot_deadline_for_app; pub(crate) use helpers::{ - char_offset, focused_border_style, jump_legend, path_extension, render_search_bar, - render_selectable_list, status_color, + char_offset, current_caret_lit, focused_border_style, jump_legend, path_extension, + render_search_bar, render_selectable_list, status_color, }; pub(crate) use hint_bar::{ HintClick, empty_hint_click_at, hint_click_at, hint_spans, render_hint_bar, @@ -89,10 +90,9 @@ pub fn draw_empty( ), } - // Shares `render_notice_row`'s row assignment so the dialog looks the same - // wherever it opens: the input on this row, its reports and keys on the - // hint row. With no project there is no repo header to fall back to, so - // outside the dialog the row carries a notice or goes empty. + // Shares `render_notice_row`'s row assignment so the dialog looks the + // same wherever it opens: the input on this row, its reports and keys on + // the hint row. With no project there is no repo header to fall back to. let notice_line = if chrome.repo_input.active { repo_dialog::repo_input_line(chrome.repo_input, accent, rows.notice.width) } else { @@ -102,8 +102,7 @@ pub fn draw_empty( frame.render_widget(Paragraph::new(notice_line), rows.notice); // The armed prefix shows the same chip as the project screen: pressing - // the leader here has to look like it did something, or it reads as a - // dead key. + // the leader here has to look like it did something. let hint = if chrome.repo_input.active { repo_dialog::repo_dialog_hint_line(notice, chrome.repo_input, rows.hint.width) } else if prefix_armed { @@ -131,11 +130,10 @@ pub fn draw( layout: &LayoutConfig, accent: Color, ) { - // Chrome: the project tab row on top, the notice row (repo identity, or a - // notice covering it) and the hint bar below. The tab row and notice row - // are rendered here, before any layout branch, so neither is lost to a - // fullscreen view mode — a tab row that vanished in fullscreen would - // strand the user with no indication of which project they are in. + // Chrome: the project tab row on top, the notice row and the hint bar + // below. Both are rendered here, before any layout branch, so neither is + // lost to a fullscreen view mode — a tab row that vanished in fullscreen + // would strand the user with no indication of which project they are in. let rows = chrome_rows(frame.area()); let (body_area, notice_area, hint_area) = (rows.body, rows.notice, rows.hint); @@ -155,9 +153,9 @@ pub fn draw( notice_area, ); - // The browser owns the body while it is open, ahead of every view-mode and - // fullscreen branch: the dialog already holds all the keys, so whatever - // those branches would draw is inert and would only hide the browse. + // The browser owns the body while it is open, ahead of every view-mode + // and fullscreen branch: the dialog already holds all the keys, so + // whatever those branches would draw is inert and would only hide it. if let Some(tree) = tabs.repo_input.picker.as_ref() { path_tree::render(frame, tree, body_area, accent); frame.render_widget( @@ -178,7 +176,7 @@ pub fn draw( return; } - if app.diff.fullscreen { + if app.diff_pane().fullscreen { diff_viewer::render(frame, app, body_area, ss, ts, accent); frame.render_widget( render_hint_bar(app, tabs, accent, hint_area.width), @@ -188,7 +186,7 @@ pub fn draw( } if app.list_fullscreen { - match app.mode { + match app.mode() { ViewMode::Status => file_list::render(frame, app, body_area, accent), ViewMode::Log => commit_list::render(frame, app, body_area, accent), ViewMode::Tree => tree_list::render(frame, app, body_area, accent), @@ -215,7 +213,7 @@ pub fn draw( ]) .split(main[0]); - match app.mode { + match app.mode() { ViewMode::Status => file_list::render(frame, app, upper[0], accent), ViewMode::Log => commit_list::render(frame, app, upper[0], accent), ViewMode::Tree => tree_list::render(frame, app, upper[0], accent), diff --git a/src/ui/notice.rs b/src/ui/notice.rs index ce3830ba..9007654b 100644 --- a/src/ui/notice.rs +++ b/src/ui/notice.rs @@ -16,16 +16,20 @@ pub(crate) fn render_notice_row<'a>( accent: Color, width: u16, ) -> Paragraph<'a> { - // The open dialog takes the header's row whole: the header names the repo - // being left, the input names the one being opened. Notices and the Tab - // candidates follow the dialog down to the hint row for the duration - // (`repo_dialog_hint_line`), so nothing covers the path being typed. + // The dialog owns this row wholesale: the header names the repo being + // left, the input names the one being opened. Notices follow the dialog + // down to the hint row so nothing covers the path being typed. if repo_input.active { return Paragraph::new(crate::ui::repo_dialog::repo_input_line( repo_input, accent, width, )); } - match notice_or_candidates(app.notice.as_ref(), repo_input, Some(&app.repo_path), width) { + match notice_or_candidates( + app.notice.as_ref(), + repo_input, + Some(app.repository_path()), + width, + ) { Some(line) => Paragraph::new(line), None => render_repo_header(app, accent, width), } @@ -33,16 +37,8 @@ pub(crate) fn render_notice_row<'a>( /// The row's content when something wants to claim it: a notice first, then /// the repo dialog's completion candidates. `None` leaves the row to the -/// caller's own fallback — the repo header on the notice row, the dialog's key -/// legend on the hint row, nothing on the empty screen. -/// -/// A notice outranks the candidates because it explains a rejected action, and -/// any edit (Tab included) clears it, so the two rarely compete for long. -/// -/// When a notice is present and a `repo_path` is available, the repo path is -/// shown on the same line alongside the notice text. If the combined width -/// exceeds the available space, the path is kept and the notice is truncated -/// with `…`. +/// caller's own fallback. A notice outranks the candidates because it +/// explains a rejected action, and any edit clears it. pub(crate) fn notice_or_candidates<'a>( notice: Option<&'a Notice>, repo_input: &RepoInput, @@ -69,10 +65,9 @@ pub(crate) fn notice_or_candidates<'a>( ])); } - // Truncate notice to fit alongside the path. let available = (width as usize).saturating_sub(path_width); - // One column is enough for the ellipsis alone: a notice cut to - // nothing must still say it was there, as `+N more` does. + // A notice cut to nothing must still say it was there, as `+N + // more` does — hence the ellipsis alone when only one column fits. if available == 0 { return Some(Line::from(vec![Span::styled(path_str, path_style)])); } @@ -97,16 +92,15 @@ pub(crate) fn notice_or_candidates<'a>( } /// Fit as many candidate names as the row holds, reporting the rest as -/// `+N more`. The row is one line, so a long list has to be cut somewhere and -/// dropping the tail silently would read as "that is all there is". +/// `+N more`: dropping the tail silently would read as "that is all there is". fn candidate_line(candidates: &[String], width: u16) -> String { let width = width as usize; let mut line = String::new(); let mut shown = 0; for name in candidates { let next = format!("{}{name}", if shown == 0 { " " } else { CANDIDATE_GAP }); - // Reserve room for the count this name would push into the overflow, so - // the last name placed can never crowd out its own `+N more`. + // Reserve room for the `+N more` this name would push into, so the + // last name placed can never crowd out its own overflow label. let overflow = overflow_label(candidates.len() - shown - 1); if Span::raw(&line).width() + Span::raw(&next).width() + Span::raw(&overflow).width() > width @@ -127,14 +121,11 @@ fn overflow_label(remaining: usize) -> String { format!("{CANDIDATE_GAP}+{remaining} more") } -/// Truncate `text` to fit within `max_width` columns, appending `…` when -/// truncation is needed. The ellipsis itself counts toward the width. -/// +/// Truncate `text` to fit within `max_width` columns, appending `…` when cut. /// Width is summed per character, so a sequence whose width is not the sum of -/// its parts — a variation selector, a combining mark — can come out a column -/// over. Measuring the string again after each character would make this row -/// quadratic in its own width on every frame, and what it buys is a column at -/// the end of a line the terminal clips anyway. +/// its parts (a variation selector, a combining mark) can come out a column +/// over — re-measuring per character would make this quadratic per frame for +/// a column the terminal clips anyway. fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { if Span::raw(text).width() <= max_width { return text.to_string(); @@ -159,29 +150,23 @@ fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { result } -/// How much of the room left for the two names the branch may take when the -/// path wants it as well. The web footer splits it the same way -/// (`RepoShell.tsx`), so the same repository reads the same on both screens. +/// How much of the room left the branch may take when the path wants it as +/// well. The web footer splits it the same way (`RepoShell.tsx`), so the same +/// repository reads the same on both screens. const BRANCH_NAME_SHARE: usize = 2; /// The path and the branch as the row can hold them, cut with `…` rather than -/// pushed off the end. -/// -/// `budget` is what the counts after them have left. Both give way, because -/// those counts do not: a name allowed to keep its length would take the row -/// from `↑N ↓M` and the recovery chip, which are the part of this row that is -/// news. The branch is held to half of what there is so a long one does not -/// take the path's place entirely, and dropped altogether when half is nothing -/// — an ellipsis alone names no branch and still costs the column it is cut to -/// fit. +/// pushed off the end. Both give way before the counts behind them, which are +/// the news on this row. The branch is held to half of `budget` so a long one +/// does not take the path's place entirely, and dropped when half is nothing — +/// an ellipsis alone names no branch. pub(crate) fn fit_names( path: &str, branch: Option<&str>, budget: usize, ) -> (String, Option) { - // Nothing left is nothing shown. `truncate_with_ellipsis` never returns - // less than the ellipsis, which on a row this full is a column taken from - // the chip it was making room for. + // Nothing left is nothing shown: the ellipsis `truncate_with_ellipsis` + // always returns would take a column from the chip it was making room for. if budget == 0 { return (String::new(), None); } @@ -201,21 +186,18 @@ pub(crate) fn fit_names( pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color, width: u16) -> Paragraph<'a> { let tracking = app - .tracking - .as_ref() + .tracking() .filter(|t| t.ahead > 0 || t.behind > 0) .map(|t| format!(" ^{} v{} ", t.ahead, t.behind)); let chip = recovery_chip(app); - // The counts and the chip keep their room: each is short, and each says - // something no other row does. let kept: usize = [tracking.as_deref(), chip.as_deref()] .into_iter() .flatten() .map(|text| Span::raw(text).width()) .sum(); let (path, branch) = fit_names( - &home_relative_path(&app.repo_path), - app.branch_name.as_deref(), + &home_relative_path(app.repository_path()), + app.branch_name(), (width as usize).saturating_sub(kept), ); @@ -245,14 +227,10 @@ pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color, width: u16) -> Paragraph::new(Line::from(spans)) } -/// The full recovery report as one chip: which pane, the plugin's state, the -/// deadline as a local wall-clock time, the attempts spent, and the detail line. -/// -/// On this row rather than in a row or overlay of its own for the reason the -/// notices are: a row that appears and disappears resizes every open PTY. It is -/// the last chip, so an actual notice still covers the whole line — a rejected -/// action needs explaining more than a wait does. The pane it describes is the -/// one ` c` would cancel (see `TerminalState::recovery_focus`). +/// The full recovery report as one chip, on this row rather than a row of its +/// own for the reason the notices are: a row that appears and disappears +/// resizes every open PTY. It is the last chip, so an actual notice still +/// covers the whole line. fn recovery_chip(app: &App) -> Option { let (pane, report) = app.terminal.recovery_focus()?; let mut chip = format!(" pane {pane}: {}", report.state); diff --git a/src/ui/path_tree.rs b/src/ui/path_tree.rs index f090e221..4e6d3810 100644 --- a/src/ui/path_tree.rs +++ b/src/ui/path_tree.rs @@ -1,9 +1,6 @@ -//! The repo dialog's directory browser, drawn over the whole body. -//! -//! Not a floating box: nothing in this crate floats — every surface takes a -//! layout area — and mouse capture is on by default, so an overlay would be the -//! first thing needing a hit region of its own. Taking the body avoids both, and -//! the path field stays visible on the notice row underneath. +//! The repo dialog's directory browser, drawn over the whole body rather +//! than floating: every surface in this crate takes a layout area, and mouse +//! capture is on by default, so an overlay would need a hit region of its own. use crate::ui::render_selectable_list; use crate::workspace::PathTree; diff --git a/src/ui/project_tab/mod.rs b/src/ui/project_tab/mod.rs index 28d89ead..9fde94d7 100644 --- a/src/ui/project_tab/mod.rs +++ b/src/ui/project_tab/mod.rs @@ -12,25 +12,22 @@ use std::time::Duration; /// Per-tab character budget for the project name. The viewer's tab row applies /// the same budget by the same rule (`viewer-ui/src/lib/tabLabel.ts`), so a -/// project is called the same thing on both screens — widening one without the -/// other is how they come to disagree. +/// project is called the same thing on both screens. const TAB_TITLE_MAX_CHARS: usize = 14; -/// Width of a `+N` overflow marker. const MARKER_WIDTH: u16 = 4; const ATTENTION_GLYPH: char = '•'; const ATTENTION_BLINK_INTERVAL: Duration = Duration::from_secs(1); -/// Bright/dim phase for the unread marker. Only style changes between phases, -/// so the row and its pointer hit boxes never move while it blinks. +/// Only style changes between phases, so the row and its pointer hit boxes +/// never move while it blinks. pub(crate) fn blink_is_bright(elapsed: Duration) -> bool { (elapsed.as_millis() / ATTENTION_BLINK_INTERVAL.as_millis()).is_multiple_of(2) } -/// The name shown for a repo path — its final component. Goes through `Path` -/// rather than splitting on `/` so a Windows path (`C:\work\api`) yields -/// `api` too. +/// Goes through `Path` rather than splitting on `/` so a Windows path +/// (`C:\work\api`) yields `api` too. pub(crate) fn tab_label(repo_path: &str) -> String { let path = std::path::Path::new(repo_path); let name = path @@ -55,7 +52,7 @@ fn truncate(s: &str, max: usize) -> String { /// The full text of every tab, ignoring how many will fit. Every tab carries /// its `F#` legend because the F-key row addresses projects directly and -/// layout-independently. Projects past the tenth have no key, so they carry +/// layout-independently; projects past the tenth have no key, so they carry /// no legend rather than implying an unbound one. fn tab_texts(repo_paths: &[String], attention: &[bool]) -> Vec { repo_paths @@ -75,11 +72,9 @@ fn tab_texts(repo_paths: &[String], attention: &[bool]) -> Vec { } /// The run of tabs to draw in `width` cells, always containing `active`. -/// Ten tabs of repo names do not fit an 80-column row, and a `Paragraph` -/// would clip the tail — silently hiding later projects *and* the active-tab -/// highlight when the active one falls off the end. So the row scrolls -/// around the active tab, and what is dropped is replaced by a `+N` marker -/// whose width is reserved here before deciding what fits. +/// A `Paragraph` would silently clip the tail — hiding later projects *and* +/// the active-tab highlight — so the row scrolls around the active tab and +/// drops what doesn't fit into `+N` markers whose width is reserved first. fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range { let n = widths.len(); if n == 0 { @@ -96,7 +91,7 @@ fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range< used.saturating_add(markers * MARKER_WIDTH) <= width }; - // Grow right first, then left. Right-first keeps the common case (active + // Grow right first, then left: right-first keeps the common case (active // near the front) showing the projects that follow it. loop { let mut grew = false; @@ -165,7 +160,7 @@ fn tab_segments( /// Draw the tab row into `area`. A single project still renders its tab: the /// row is permanent (see `chrome_rows`), and showing which repo is open is -/// exactly what the row is for. `accent` marks the active tab. +/// exactly what the row is for. pub(crate) fn render( repo_paths: &[String], attention: &[bool], @@ -224,7 +219,7 @@ pub(crate) fn render( } /// The project index a click at screen cell `(x, y)` selects, or `None` off -/// the row or past the last tab. `area` is the tab row Rect. +/// the row or past the last tab. pub(crate) fn tab_at( repo_paths: &[String], attention: &[bool], @@ -234,8 +229,8 @@ pub(crate) fn tab_at( y: u16, ) -> Option { // On a terminal too short for the full chrome, ratatui hands the fixed tab - // constraint a zero-height Rect and nothing is drawn. Without the size - // check a click on whatever *is* visible at that y would select tab 0. + // constraint a zero-height Rect and nothing is drawn; without the size + // check a click on whatever is visible at that y would select tab 0. if area.height == 0 || area.width == 0 || y != area.y || x < area.x { return None; } diff --git a/src/ui/repo_dialog.rs b/src/ui/repo_dialog.rs index f43201fb..07dae40b 100644 --- a/src/ui/repo_dialog.rs +++ b/src/ui/repo_dialog.rs @@ -11,13 +11,10 @@ use ratatui::{ /// The dialog's input line. Drawn on the notice row, in the repo header's /// place: the header names the repo being left, the input names the one being -/// opened, and only one of those is being decided right now. Owning a whole -/// row means the path never has to compete with the key legend, which sits on -/// the hint row below (`repo_dialog_hint_line`). -/// -/// A path longer than the row is shown from its tail behind a leading `…` — -/// the caret marks where typing lands, so it is the end that must survive. -/// `width` is the row's; 0 means "unknown", which keeps the whole path. +/// opened, and only one of those is being decided right now. A path longer +/// than the row is shown from its tail behind a leading `…` — the caret marks +/// where typing lands, so it is the end that must survive. `width` is the +/// row's; 0 means "unknown", which keeps the whole path. pub(crate) fn repo_input_line<'a>( repo_input: &'a RepoInput, accent: Color, diff --git a/src/ui/search.rs b/src/ui/search.rs index 36c72c23..13033a72 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -1,7 +1,6 @@ /// Search-input string paired with its lowercased form. Bundling the /// invariant into one type keeps callers honest: pushing or popping always -/// updates both halves in lockstep, and renderers/filters read the canonical -/// lower form through `lower()`. +/// updates both halves in lockstep. #[derive(Default, Clone, Debug)] pub struct SearchQuery { raw: String, diff --git a/src/ui/splash.rs b/src/ui/splash.rs index 6ef9249c..7e82c105 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash.rs @@ -76,7 +76,7 @@ pub fn draw(frame: &mut Frame, state: &SplashState, accent: Color) { ]) .split(outer[1]); - // Logo — brighten as loading completes + // Brighten the logo as loading completes. let progress = state.progress(); let logo_style = if progress < 0.5 { Style::default().fg(accent).add_modifier(Modifier::DIM) diff --git a/src/ui/status_view.rs b/src/ui/status_view.rs index 97578830..8a447260 100644 --- a/src/ui/status_view.rs +++ b/src/ui/status_view.rs @@ -14,7 +14,7 @@ pub struct StatusView { /// Indices into `files` matching `search_query`. Recomputed only when /// `files` or the query changes (see `App::recompute_status_filter`). pub(crate) filter_cache: Vec, - /// Per-file mtime observed at the latest snapshot, keyed by `path`. Used + /// Per-file mtime observed at the latest snapshot, keyed by `path`, used /// by the agent-aware focus indicator to decide whether a file is "hot". /// Entries for paths missing from the latest snapshot are dropped each /// tick so the map stays bounded by the working-tree change count. diff --git a/src/ui/terminal_tab/cells.rs b/src/ui/terminal_tab/cells.rs index 713eb8a5..3b48b0e1 100644 --- a/src/ui/terminal_tab/cells.rs +++ b/src/ui/terminal_tab/cells.rs @@ -21,8 +21,8 @@ pub(crate) struct VisiblePaneCell { /// Lay out every currently visible pane inside `content_area` (the terminal /// body, below the tab row). Single source of truth for pane sizing: `render` /// draws from it and `visible_pane_content_areas` (used to resize each pane's -/// PTY) reads from it, so a pane's backend/emulator size always matches what's -/// actually drawn on screen. +/// PTY) reads from it, so a pane's backend/emulator size always matches what +/// is drawn on screen. pub(crate) fn visible_pane_cells(app: &App, content_area: Rect) -> Vec { let pane_count = app.terminal.panes.len(); let visible = visible_range( diff --git a/src/ui/terminal_tab/layout.rs b/src/ui/terminal_tab/layout.rs index 79e26bb9..afa0eaa1 100644 --- a/src/ui/terminal_tab/layout.rs +++ b/src/ui/terminal_tab/layout.rs @@ -22,9 +22,9 @@ pub(crate) const TAB_TITLE_MAX_CHARS: usize = 20; pub(crate) const JUMP_KEY_PANE_COUNT: usize = MAX_VISIBLE_FULLSCREEN; /// Truncate `title` to at most `max` characters, appending `…` when cut. -/// Char-based (not display-width) for simplicity: ASCII shell program names -/// are the common case and `chars().count()` is already correct there. CJK -/// titles render slightly under the visual budget, which is acceptable. +/// Char-based (not display-width): ASCII shell program names are the common +/// case, and CJK titles render slightly under the visual budget, which is +/// acceptable. pub(crate) fn truncate_tab_title(title: &str, max: usize) -> String { if title.chars().count() <= max { return title.to_string(); @@ -48,13 +48,11 @@ pub(crate) fn terminal_layout(area: Rect) -> Option<(Rect, Rect)> { Some((chunks[0], chunks[1])) } -/// Split `area` into `count` cells using a balanced grid: 1 pane fills the -/// area; 2 panes go side by side when `area` is wide, stacked otherwise; 3 -/// panes get a 2-column row plus a full-width remainder row; 4 is a 2x2 grid; -/// 5-6 use 3 columns; 7 uses a 4-then-3 row split; 8 is a 2x4 grid. Counts -/// beyond that (not expected given `MAX_VISIBLE_FULLSCREEN`) fall back to a -/// near-square grid. Every returned Rect has at least 1x1 size when `area` -/// is at least `count` cells large, so no cell silently disappears. +/// Split `area` into `count` cells using a balanced grid: 2 panes go side by +/// side when the area is wide, stacked otherwise; 3-8 panes get fixed layouts +/// (2x2, 3-col, 4-then-3, 2x4); counts beyond that fall back to a near-square +/// grid. Every returned Rect has at least 1x1 size when `area` is at least +/// `count` cells large, so no cell silently disappears. pub(crate) fn split_pane_areas(area: Rect, count: usize) -> Vec { if count == 0 || area.width == 0 || area.height == 0 { return Vec::new(); diff --git a/src/ui/terminal_tab/mod.rs b/src/ui/terminal_tab/mod.rs index 0b8d29c6..ff843029 100644 --- a/src/ui/terminal_tab/mod.rs +++ b/src/ui/terminal_tab/mod.rs @@ -36,9 +36,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { " Terminal " }; // The upper panes draw a `┌` corner that pushes their title text in by one - // column (`┌ ^F 1 Files`). This pane has no left border, so a border-styled - // `─` stands in for that corner — it keeps `Terminal` column-aligned with - // `^F 1 Files` / `^F 2 Diff` above and makes the line start flush at the edge. + // column. This pane has no left border, so a border-styled `─` stands in + // for that corner to keep `Terminal` column-aligned with the titles above. let title = Line::from(vec![Span::styled("─", border_style), Span::raw(label)]); let block = Block::default() .borders(TERMINAL_BORDERS) @@ -77,10 +76,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let i = visible.start + offset; let is_active = i == app.terminal.active; if cell.bordered { - // `accent` means "this is where your keystrokes go right now" — - // reserved for Focus::Terminal. Without real focus, the active - // pane must look identical to an inactive one (plain DarkGray) — - // any brighter treatment reads as focused when it isn't. + // `accent` means "this is where your keystrokes go right now" and + // is reserved for Focus::Terminal — without real focus the active + // pane must look identical to an inactive one. let pane_border_style = if is_active && focused { Style::default().fg(accent) } else { diff --git a/src/ui/terminal_tab/recovery.rs b/src/ui/terminal_tab/recovery.rs index 9a7a55df..c22b28e9 100644 --- a/src/ui/terminal_tab/recovery.rs +++ b/src/ui/terminal_tab/recovery.rs @@ -1,23 +1,17 @@ -//! The recovery marker a pane's tab label carries. -//! -//! Deliberately a *suffix on an existing label* rather than a row or an overlay: -//! adding or removing a layout row resizes every open PTY (see the Layout and -//! Notice Row sections of `docs/architecture.md`), and a badge that comes and -//! goes would do that every time a plugin changed its mind. The full report — -//! state, deadline, attempt and detail — is on the notice row; this is only the -//! "which pane" pointer, so it has to stay short. +//! The recovery marker a pane's tab label carries: a suffix on an existing +//! label rather than a row or an overlay, because adding or removing a layout +//! row resizes every open PTY (see `docs/architecture.md`). The full report +//! lives on the notice row; this is only the "which pane" pointer. use crate::app::App; use crate::runtime::terminal::PaneRecovery; use crate::ui::terminal_tab::layout::{TAB_TITLE_MAX_CHARS, truncate_tab_title}; use crate::ui::wall_clock::local_hour_minute; -/// Chars of the pane title kept when a marker rides along. -/// -/// Well under [`TAB_TITLE_MAX_CHARS`](super::layout::TAB_TITLE_MAX_CHARS): the -/// title is truncated to make room *before* the marker is appended, so a narrow -/// pane loses title characters rather than the marker. Losing the marker is the -/// one degradation that defeats the point of having it. +/// Chars of the pane title kept when a marker rides along — well under +/// [`TAB_TITLE_MAX_CHARS`](super::layout::TAB_TITLE_MAX_CHARS) so the title is +/// truncated before the marker is appended. Losing the marker would defeat +/// the point of having it. pub(crate) const RECOVERY_TITLE_MAX_CHARS: usize = 8; /// A wait with a known end. @@ -43,11 +37,9 @@ pub(crate) fn pane_label(app: &App, index: usize) -> String { } /// The marker for one pane's report: the deadline as a local wall-clock time -/// when there is one, and the attempt count when any have been spent. -/// -/// A report with neither is still marked, with the bare hourglass — a pane its -/// plugin is doing something about must be distinguishable from one it is not, -/// even when there is no number to show. +/// when there is one, and the attempt count when any have been spent. A +/// report with neither is still marked with the bare hourglass — a pane its +/// plugin is doing something about must be distinguishable from one it is not. pub(crate) fn recovery_marker(report: &PaneRecovery) -> String { let mut marker = String::new(); if let Some(at) = report.deadline_epoch.and_then(local_hour_minute) { diff --git a/src/ui/terminal_tab/screen.rs b/src/ui/terminal_tab/screen.rs index ba9057e9..634743a5 100644 --- a/src/ui/terminal_tab/screen.rs +++ b/src/ui/terminal_tab/screen.rs @@ -35,10 +35,9 @@ pub(crate) fn build_screen_lines( let mut style = Style::default(); let cell = match screen.cell(row, col) { Some(cell) => { - // Wide chars (e.g., Hangul) occupy two columns: the glyph - // lives on the first cell and a spacer fills the second. - // Emitting anything for the spacer would shift the row by one - // column. + // Wide chars occupy two columns: the glyph lives on the + // first cell and a spacer fills the second; emitting + // anything for the spacer would shift the row. if cell.is_wide_spacer() { continue; } @@ -86,10 +85,8 @@ pub(crate) fn screen_cursor_position(screen: &ScreenView<'_>, area: Rect) -> Opt } // Embedded CLIs such as Claude can leave DECTCEM hide-cursor mode enabled - // while still expecting an outer terminal host to expose the input point. - // For the focused terminal pane, keep the host cursor visible at the - // emulator's tracked cursor position instead of honoring the inner app's - // hide flag. + // while still expecting an outer terminal host to expose the input point, + // so keep the host cursor visible at the emulator's tracked position. let (row, col) = screen.cursor_position(); Some(Position::new( area.x.saturating_add(col.min(area.width.saturating_sub(1))), diff --git a/src/ui/terminal_tab/tab_bar.rs b/src/ui/terminal_tab/tab_bar.rs index bcbd5e61..fd16d651 100644 --- a/src/ui/terminal_tab/tab_bar.rs +++ b/src/ui/terminal_tab/tab_bar.rs @@ -68,13 +68,11 @@ pub(crate) fn tab_segments( segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( |(offset, _pane)| { let i = visible.start + offset; - // Panes 0..=7 carry a jump key: ` 1..8` in fullscreen, - // ` 3..9,0` in the split view (the digit row is - // layout-aware). Panes past the 8th have no jump key, so they - // carry no hint to avoid implying an unbound shortcut. The bare - // F-keys are NOT advertised here: they select project tabs. - // Carries the recovery marker when the pane has one, so a pane its - // plugin is nursing back is visible without leaving the tab row. + // Panes 0..=7 carry a jump key; panes past the 8th carry no hint + // to avoid implying an unbound shortcut. The bare F-keys are NOT + // advertised here: they select project tabs. The label carries + // the recovery marker when the pane has one, so a pane its plugin + // is nursing back is visible without leaving the tab row. let title = pane_label(app, i); let label = if i < JUMP_KEY_PANE_COUNT { // Split view runs 3,4..9 then wraps to 0 for the eighth pane. diff --git a/src/ui/tests/chrome_tests.rs b/src/ui/tests/chrome_tests.rs index df460423..0d2de437 100644 --- a/src/ui/tests/chrome_tests.rs +++ b/src/ui/tests/chrome_tests.rs @@ -57,7 +57,7 @@ fn the_project_screen_puts_the_dialog_on_the_notice_row_and_its_reports_below() // The wiring, not the helpers: the input must land on the notice row in // the repo header's place, and the rejection on the hint row under it. let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = "/tmp/somewhere".to_string(); + app.git.repo_path = "/tmp/somewhere".to_string(); app.raise_notice(NoticeKind::RepoInput, "no such directory"); let repo_input = RepoInput { active: true, @@ -132,7 +132,7 @@ fn the_project_tab_row_survives_every_fullscreen_mode() { ); let mut app = app_with_files(vec!["a.rs"]); - app.diff.fullscreen = true; + app.git.view.diff.fullscreen = true; assert!( drawn_text(&mut app, &paths, 0).contains("F2 web"), "diff fullscreen" diff --git a/src/ui/tests/hint_armed_tests.rs b/src/ui/tests/hint_armed_tests.rs index 14c4bf05..7accacee 100644 --- a/src/ui/tests/hint_armed_tests.rs +++ b/src/ui/tests/hint_armed_tests.rs @@ -109,14 +109,14 @@ fn prefix_hint_names_view_toggle_destinations_by_mode() { "status mode armed row must name log/tree destinations, got: {text}" ); - app.mode = ViewMode::Log; + app.git.view.mode = ViewMode::Log; let text = hint_text(&app); assert!( text.contains("l: status view") && text.contains("b: tree view"), "log mode armed row must name status/tree destinations, got: {text}" ); - app.mode = ViewMode::Tree; + app.git.view.mode = ViewMode::Tree; let text = hint_text(&app); assert!( text.contains("l: log view") && text.contains("b: status view"), @@ -131,7 +131,7 @@ fn prefix_hint_names_view_toggle_destinations_by_mode() { fn upper_legends_advertise_both_view_toggles() { // FileList browsing commits in Log mode. let mut app = app_with_fake_backend(); - app.mode = ViewMode::Log; + app.git.view.mode = ViewMode::Log; let text = hint_text(&app); assert!( text.contains("l: status view") && text.contains("b: tree view"), @@ -162,13 +162,13 @@ fn upper_legends_advertise_both_view_toggles() { text.contains("l: log view") && text.contains("b: tree view"), "zoomed status list must offer both toggles, got: {text}" ); - zoomed.mode = ViewMode::Log; + zoomed.git.view.mode = ViewMode::Log; let text = hint_text(&zoomed); assert!( text.contains("l: status view") && text.contains("b: tree view"), "zoomed log list must offer both toggles, got: {text}" ); - zoomed.mode = ViewMode::Tree; + zoomed.git.view.mode = ViewMode::Tree; let text = hint_text(&zoomed); assert!( text.contains("b: status view") && text.contains("l: log view"), diff --git a/src/ui/tests/hint_click_tests.rs b/src/ui/tests/hint_click_tests.rs index c5fe42cb..f53076db 100644 --- a/src/ui/tests/hint_click_tests.rs +++ b/src/ui/tests/hint_click_tests.rs @@ -234,7 +234,7 @@ fn hint_click_armed_row_resolves_the_remaining_commands() { fn hint_click_resolves_the_search_match_keys() { let mut app = app_with_fake_backend(); app.focus = Focus::DiffViewer; - app.diff.search.query.set("foo"); + app.git.view.diff.search.query.set("foo"); for (needle, key) in [("n: next match", 'n'), ("shift+n: prev match", 'N')] { let x = hint_x_of(&app, needle); diff --git a/src/ui/tests/hint_diff_tests.rs b/src/ui/tests/hint_diff_tests.rs index 1b4e3204..40918f4f 100644 --- a/src/ui/tests/hint_diff_tests.rs +++ b/src/ui/tests/hint_diff_tests.rs @@ -28,7 +28,7 @@ fn normal_hint_advertises_close_only_with_terminal_focus() { fn diff_hint_advertises_view_file_only_with_a_file_target() { // Log view browsing commits (no drill-down): `v` has no target. let mut app = app_with_fake_backend(); - app.mode = ViewMode::Log; + app.git.view.mode = ViewMode::Log; app.focus = Focus::DiffViewer; let text = hint_text(&app); assert!( @@ -41,7 +41,7 @@ fn diff_hint_advertises_view_file_only_with_a_file_target() { ); // Same state zoomed: the fullscreen legend must agree. - app.diff.fullscreen = true; + app.git.view.diff.fullscreen = true; let text = hint_text(&app); assert!( !text.contains("v: view file"), @@ -49,8 +49,10 @@ fn diff_hint_advertises_view_file_only_with_a_file_target() { ); // Drill-down with a file selected: `v` acts, so advertise it. - app.diff.fullscreen = false; - app.log_view + app.git.view.diff.fullscreen = false; + app.git + .view + .log .set_commits(vec![crate::git::diff::CommitEntry::new( git2::Oid::ZERO_SHA1, "deadbee".to_string(), @@ -58,8 +60,8 @@ fn diff_hint_advertises_view_file_only_with_a_file_target() { "T".to_string(), 0, )]); - app.log_view.drill_down = true; - app.log_view.commit_files = vec![crate::git::diff::ChangedFile::unstaged_only( + app.git.view.log.drill_down = true; + app.git.view.log.commit_files = vec![crate::git::diff::ChangedFile::unstaged_only( "a.rs".to_string(), StatusKind::Modified, )]; @@ -86,9 +88,9 @@ fn every_view_that_wraps_advertises_the_key() { let mut app = app_with_fake_backend(); app.focus = Focus::DiffViewer; for view in [DiffPaneView::Diff, DiffPaneView::File] { - app.diff.view = view; + app.git.view.diff.view = view; for zoomed in [false, true] { - app.diff.fullscreen = zoomed; + app.git.view.diff.fullscreen = zoomed; let text = hint_text(&app); assert!( text.contains("w: wrap"), @@ -99,13 +101,13 @@ fn every_view_that_wraps_advertises_the_key() { // Tree mode's right pane is permanently the file view, and wraps the same. let mut tree = app_with_fake_backend(); - tree.mode = ViewMode::Tree; + tree.git.view.mode = ViewMode::Tree; tree.focus = Focus::DiffViewer; - tree.diff.view = DiffPaneView::File; + tree.git.view.diff.view = DiffPaneView::File; assert!(hint_text(&tree).contains("w: wrap"), "tree file view wraps"); - app.diff.view = DiffPaneView::Split; - app.diff.fullscreen = false; + app.git.view.diff.view = DiffPaneView::Split; + app.git.view.diff.fullscreen = false; let text = hint_text(&app); assert!( !text.contains("w: wrap"), @@ -118,16 +120,16 @@ fn every_view_that_wraps_advertises_the_key() { #[test] fn tree_file_view_hint_omits_back_to_diff() { let mut app = app_with_fake_backend(); - app.mode = ViewMode::Tree; + app.git.view.mode = ViewMode::Tree; app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::File; + app.git.view.diff.view = DiffPaneView::File; let text = hint_text(&app); assert!( !text.contains("v: back to diff"), "tree file-view legend must not offer back to diff, got: {text}" ); - app.diff.fullscreen = true; + app.git.view.diff.fullscreen = true; let text = hint_text(&app); assert!( !text.contains("v: back to diff"), diff --git a/src/ui/tests/hint_legend_tests.rs b/src/ui/tests/hint_legend_tests.rs index 520e1c4e..65913508 100644 --- a/src/ui/tests/hint_legend_tests.rs +++ b/src/ui/tests/hint_legend_tests.rs @@ -115,7 +115,7 @@ fn rows_carrying_the_added_commands_invert_only_clickable_labels() { let mut searched = app_with_fake_backend(); searched.focus = Focus::DiffViewer; - searched.diff.search.query.set("foo"); + searched.git.view.diff.search.query.set("foo"); assert_inverted_cells_are_clickable(&searched); } diff --git a/src/ui/tests/notice_tests.rs b/src/ui/tests/notice_tests.rs index d3c0bbf5..3acdc2bb 100644 --- a/src/ui/tests/notice_tests.rs +++ b/src/ui/tests/notice_tests.rs @@ -157,7 +157,7 @@ fn the_empty_screen_shows_completion_candidates_too() { #[test] fn notice_row_falls_back_to_repo_identity() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/somewhere".to_string(); + app.git.repo_path = "/tmp/somewhere".to_string(); let before = notice_text(&app); assert!(before.contains("/tmp/somewhere"), "got: {before}"); @@ -181,7 +181,7 @@ fn notice_row_falls_back_to_repo_identity() { #[test] fn 공지가_뜨면_저장소_경로도_함께_보인다() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/my-project".to_string(); + app.git.repo_path = "/tmp/my-project".to_string(); app.raise_notice(NoticeKind::Git, "not a git repository"); let text = notice_text(&app); @@ -200,7 +200,7 @@ fn 공지가_뜨면_저장소_경로도_함께_보인다() { #[test] fn 좁은_너비에서는_경로가_남고_공지가_잘린다() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/p".to_string(); + app.git.repo_path = "/tmp/p".to_string(); app.raise_notice(NoticeKind::Git, "not a git repository"); let mut terminal = @@ -233,7 +233,7 @@ fn 좁은_너비에서는_경로가_남고_공지가_잘린다() { #[test] fn 공지_자리가_한_칸뿐이어도_잘렸다는_표시는_남는다() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/p".to_string(); + app.git.repo_path = "/tmp/p".to_string(); app.raise_notice(NoticeKind::Git, "not a git repository"); // `/tmp/p` renders as " /tmp/p ", so 9 columns leave exactly one. @@ -267,7 +267,7 @@ fn 공지_자리가_한_칸뿐이어도_잘렸다는_표시는_남는다() { #[test] fn 다이얼로그가_열리면_입력이_저장소_헤더를_대체한다() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/somewhere".to_string(); + app.git.repo_path = "/tmp/somewhere".to_string(); let text = notice_text_with(&app, &dialog_offering(&["nightcrow", "nightowl"])); @@ -286,7 +286,7 @@ fn 다이얼로그가_열리면_입력이_저장소_헤더를_대체한다() { #[test] fn 공지도_후보도_없으면_저장소_헤더가_보인다() { let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/somewhere".to_string(); + app.git.repo_path = "/tmp/somewhere".to_string(); let text = notice_text(&app); assert!(text.contains("/tmp/somewhere"), "got: {text}"); } diff --git a/src/ui/tree_list.rs b/src/ui/tree_list.rs index 95d899d3..8d1094f2 100644 --- a/src/ui/tree_list.rs +++ b/src/ui/tree_list.rs @@ -1,8 +1,7 @@ //! Renderer for the read-only file-tree navigator pane (`ViewMode::Tree`). -//! Rows are derived from `TreeView::visible_rows`; each is indented by depth, -//! prefixed with an expansion marker for directories, and horizontally -//! scrollable via the shared `char_offset` helper (mirroring the file/commit -//! lists). +//! Rows come from `TreeView::visible_rows`, indented by depth, with an +//! expansion marker for directories and shared `char_offset` horizontal +//! scrolling. use crate::app::{App, Focus}; use ratatui::{ @@ -13,9 +12,9 @@ use ratatui::{ widgets::ListItem, }; -// VS Code-style chevrons rather than filled triangles: a thin right chevron -// when collapsed, a down chevron when expanded. Each marker is two columns -// wide (glyph + space), matching the file marker so names stay aligned. +// VS Code-style thin chevrons rather than filled triangles; each marker is +// two columns wide (glyph + space), matching the file marker so names stay +// aligned. const EXPANDED_MARKER: &str = "⌄ "; const COLLAPSED_MARKER: &str = "› "; const FILE_MARKER: &str = " "; @@ -25,8 +24,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let border_style = super::focused_border_style(focused, accent); // Reserve a bottom row for the search input whenever the overlay is open - // or a query is still showing, mirroring the status/commit list layout. - let show_search = app.tree_view.search_active || !app.tree_view.search_query.is_empty(); + // or a query is still showing. + let show_search = app.tree_view().search_active || !app.tree_view().search_query.is_empty(); let (list_area, search_area) = if show_search { let chunks = Layout::default() .direction(Direction::Vertical) @@ -37,8 +36,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { (area, None) }; - let rows = app.tree_view.visible_rows(); - let scroll_x = app.tree_view.scroll_x; + let rows = app.tree_view().visible_rows(); + let scroll_x = app.tree_view().scroll_x; let items: Vec = rows .iter() @@ -54,11 +53,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { FILE_MARKER }; let full = format!("{indent}{marker}{}", row.name); - // Scroll the whole rendered line (indent + marker + name) so long - // nested paths can be panned into view with ←/→ when focused. let shown = super::char_offset(&full, scroll_x).to_string(); - // Directories take the accent color (and bold) so the structure - // reads at a glance; files render in the default foreground. + // Directories take the accent color so the structure reads at a + // glance; files render in the default foreground. let style = if row.is_dir { Style::default().fg(accent).add_modifier(Modifier::BOLD) } else { @@ -68,12 +65,12 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { }) .collect(); - let title = if app.tree_view.search_filtering() { + let title = if app.tree_view().search_filtering() { format!( " {} Tree ({}/{}) ", super::jump_legend(app, '1'), - app.tree_view.match_count, - app.tree_view.index.len() + app.tree_view().match_count, + app.tree_view().index.len() ) } else if rows.is_empty() { format!(" {} Tree (empty) ", super::jump_legend(app, '1')) @@ -84,7 +81,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let selected = if rows.is_empty() { None } else { - Some(app.tree_view.selected.min(rows.len() - 1)) + Some(app.tree_view().selected.min(rows.len() - 1)) }; super::render_selectable_list(frame, list_area, title, items, selected, border_style); @@ -92,8 +89,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { if let Some(sa) = search_area { super::render_search_bar( frame, - app.tree_view.search_query.as_str(), - app.tree_view.search_active, + app.tree_view().search_query.as_str(), + app.tree_view().search_active, sa, accent, ); diff --git a/src/ui/tree_view/mod.rs b/src/ui/tree_view/mod.rs index 02f76795..a85c0171 100644 --- a/src/ui/tree_view/mod.rs +++ b/src/ui/tree_view/mod.rs @@ -1,14 +1,12 @@ //! File-tree navigator state (`ViewMode::Tree`). The visible row list is -//! derived from a cache + expansion set so the two can never drift. All -//! directory I/O lives in `App`; this module is pure given a populated cache, -//! keeping the flattening logic unit-testable without a filesystem. +//! derived from a cache + expansion set so the two can never drift; all +//! directory I/O lives in `App`, keeping this module pure and unit-testable. use crate::git::tree::TreeEntry; use crate::ui::SearchQuery; use std::cell::Cell; use std::collections::{BTreeSet, HashMap, HashSet}; -/// One flattened, currently-visible tree row. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VisibleRow { pub path: String, @@ -18,8 +16,8 @@ pub struct VisibleRow { pub expanded: bool, } -/// One entry in the flat filename-search index. Built when search opens, -/// discarded when it closes. +/// Flat filename-search index entry, built when search opens and discarded +/// when it closes. #[derive(Debug, Clone)] pub(crate) struct TreeIndexEntry { pub path: String, @@ -29,7 +27,6 @@ pub(crate) struct TreeIndexEntry { #[derive(Default)] pub struct TreeView { pub selected: usize, - /// Horizontal scroll offset (chars). pub scroll_x: usize, /// Repo-relative expanded directory paths. The root (`""`) is implicitly /// expanded and never stored here. @@ -50,9 +47,8 @@ pub struct TreeView { } impl TreeView { - /// Whether the search overlay is open with a non-empty query. An open - /// overlay with an empty query still shows the expansion view so the tree - /// does not explode before the user types. + /// An open overlay with an empty query still shows the expansion view so + /// the tree does not explode before the user types. pub fn search_filtering(&self) -> bool { self.search_active && !self.search_query.is_empty() } @@ -66,13 +62,12 @@ impl TreeView { self.row_width_cache.set(None); } - /// Recompute `show_set`/`match_count` from `index` and the current query. /// Each match contributes itself and every ancestor so the filtered view /// renders an unbroken path from the root to each hit. pub(crate) fn recompute_filter(&mut self) { // Collect matches under an immutable borrow first, then mutate the - // show-set — `index` and `show_set` are disjoint fields but both - // borrow `self`, so they can't be touched in the same loop. + // show-set — `index` and `show_set` both borrow `self`, so they can't + // be touched in the same loop. let matches: Vec = { let q = self.search_query.lower(); if q.is_empty() { @@ -102,7 +97,6 @@ impl TreeView { } } - /// Derive the flattened visible rows from the cache and expansion set. /// Only expanded, cached directories contribute children, so this never /// triggers I/O. While filtering, the row list is restricted to `show_set`. pub fn visible_rows(&self) -> Vec { @@ -115,8 +109,7 @@ impl TreeView { rows } - /// Filtered variant of `push_children`: include only `show_set` entries, - /// rendering every kept directory as expanded so the full path to each + /// Renders every kept directory as expanded so the full path to each /// match is visible. fn push_children_filtered(&self, dir: &str, depth: usize, rows: &mut Vec) { let Some(children) = self.cache.get(dir) else { @@ -168,16 +161,14 @@ impl TreeView { } } - /// Repo-relative path of the currently selected row, if any. Used to - /// persist/restore the cursor across sessions and refreshes. + /// Used to persist/restore the cursor across sessions and refreshes. pub fn selected_path(&self) -> Option { self.visible_rows() .get(self.selected) .map(|r| r.path.clone()) } - /// Clamp `selected` to the row count so a collapse or refresh can never - /// leave the cursor past the end. + /// So a collapse or refresh can never leave the cursor past the end. pub fn clamp_selection(&mut self, row_count: usize) { if row_count == 0 { self.selected = 0; @@ -187,16 +178,15 @@ impl TreeView { } } -/// Parent directory of a repo-relative path, or `None` for a top-level entry -/// (whose parent is the root, which has no selectable row). +/// Parent directory of a repo-relative path; `None` for a top-level entry, +/// whose parent is the root, which has no selectable row. pub fn parent_path(path: &str) -> Option<&str> { path.rfind('/').map(|i| &path[..i]) } -/// Whether `rel` is a safe, repo-internal relative path. Paths from normal -/// navigation always are, but a restored session is read from disk — a -/// hand-edited `tree_expanded` entry containing `..`, a leading `/`, or a -/// drive prefix would otherwise let the tree read outside the working tree. +/// Guards a restored session: it is read from disk, so a hand-edited +/// `tree_expanded` entry containing `..`, a leading `/`, or a drive prefix +/// would otherwise let the tree read outside the working tree. pub fn is_safe_rel_path(rel: &str) -> bool { use std::path::Component; !rel.is_empty() diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs index ecce3e65..529dda01 100644 --- a/src/ui/wall_clock.rs +++ b/src/ui/wall_clock.rs @@ -1,9 +1,6 @@ //! Turning a unix epoch second into the `HH:MM` a person reads off their own -//! clock. -//! -//! Hand-rolled because nightcrow has no date crate: adding `chrono`/`time` for -//! a handful of integers would buy a dependency and its transitive tree for two -//! format strings. +//! clock, hand-rolled because adding a date crate for two format strings +//! would buy a dependency and its transitive tree. #[cfg(any(not(any(unix, windows)), test))] const SECS_PER_MINUTE: i64 = 60; @@ -12,11 +9,10 @@ const SECS_PER_HOUR: i64 = 3_600; #[cfg(any(not(any(unix, windows)), test))] const SECS_PER_DAY: i64 = 86_400; -/// `HH:MM` in the machine's local zone, or `None` when the timestamp is one the -/// platform cannot place. -/// -/// `None` rather than a fallback on purpose: a wrong wall-clock time reads as -/// fact, and the caller is expected to show nothing instead. +/// `HH:MM` in the machine's local zone, or `None` when the timestamp is one +/// the platform cannot place. `None` rather than a fallback on purpose: a +/// wrong wall-clock time reads as fact, and the caller is expected to show +/// nothing instead. pub(crate) fn local_hour_minute(epoch: i64) -> Option { let t = local_parts(epoch)?; Some(format!("{:02}:{:02}", t.hour, t.minute)) @@ -48,9 +44,8 @@ pub(crate) struct DateTimeParts { fn local_parts(epoch: i64) -> Option { let seconds: libc::time_t = epoch.try_into().ok()?; let mut parts: libc::tm = unsafe { std::mem::zeroed() }; - // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the whole - // call; `localtime_r` reads the first and writes only into the second, and is - // the reentrant form precisely so it needs no shared state. + // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the + // whole call; `localtime_r` reads the first and writes only into the second. let filled = unsafe { libc::localtime_r(&seconds, &mut parts) }; if filled.is_null() { return None; @@ -67,16 +62,15 @@ fn local_parts(epoch: i64) -> Option { /// Windows: convert the epoch through `FileTimeToLocalFileTime` so the /// machine's current time-zone rules apply. Pre-1970 timestamps cannot be -/// represented as an unsigned `FILETIME` and return `None` — the caller -/// already handles that. +/// represented as an unsigned `FILETIME` and return `None`. #[cfg(windows)] fn local_parts(epoch: i64) -> Option { use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME}; use windows_sys::Win32::Storage::FileSystem::FileTimeToLocalFileTime; use windows_sys::Win32::System::Time::FileTimeToSystemTime; - // Windows FILETIME counts 100-nanosecond intervals since 1601-01-01 UTC. - // Unix epoch is 1970-01-01. The offset is 11,644,473,600 seconds. + // Windows FILETIME counts 100-ns intervals since 1601-01-01; Unix epoch + // is 1970-01-01. const EPOCH_OFFSET_SECS: u64 = 11_644_473_600; const HNS_PER_SEC: u64 = 10_000_000; @@ -97,7 +91,7 @@ fn local_parts(epoch: i64) -> Option { let mut st: SYSTEMTIME = unsafe { std::mem::zeroed() }; // SAFETY: local_ft and st are live stack variables; the functions write - // only into them and need no shared state. + // only into them. let ok = unsafe { FileTimeToLocalFileTime(&ft, &mut local_ft) != 0 && FileTimeToSystemTime(&local_ft, &mut st) != 0 @@ -116,18 +110,16 @@ fn local_parts(epoch: i64) -> Option { }) } -/// UTC on platforms with no `localtime_r`. The zone database is the OS's to -/// expose, and guessing an offset would be worse than being explicit about the -/// one this falls back to. +/// UTC on platforms with no `localtime_r`: guessing an offset would be worse +/// than being explicit about the one this falls back to. #[cfg(not(any(unix, windows)))] fn local_parts(epoch: i64) -> Option { utc_parts(epoch) } -/// `HH:MM` in UTC, which is what the epoch already counts. -/// -/// `epoch.rem_euclid` rather than `%` so a pre-1970 timestamp lands on the right -/// side of midnight instead of producing a negative hour. +/// `HH:MM` in UTC, which is what the epoch already counts. `rem_euclid` +/// rather than `%` so a pre-1970 timestamp lands on the right side of midnight +/// instead of producing a negative hour. #[cfg(any(not(any(unix, windows)), test))] fn utc_parts(epoch: i64) -> Option { let into_day = epoch.rem_euclid(SECS_PER_DAY); diff --git a/src/web/common/conn.rs b/src/web/common/conn.rs index ada7983c..4953d34e 100644 --- a/src/web/common/conn.rs +++ b/src/web/common/conn.rs @@ -25,9 +25,8 @@ pub const MAX_BODY_BYTES: usize = 64 * 1024; /// Per-read socket timeout while collecting the head. pub const HEAD_READ_TIMEOUT: Duration = Duration::from_secs(15); /// Wall-clock budget for the *whole* request. The socket timeout above only -/// bounds one `read`, and it re-arms on every byte — a client dribbling one -/// byte per timeout would otherwise hold a connection slot for days. This is -/// the deadline that actually ends it. +/// bounds one `read` and re-arms on every byte — a client dribbling one byte +/// per timeout would otherwise hold a connection slot for days. pub const REQUEST_DEADLINE: Duration = Duration::from_secs(30); /// Read the request head (up to CRLFCRLF) plus any declared body. Both @@ -107,13 +106,13 @@ pub fn origin_allowed(head: &RequestHead) -> bool { /// Whether the request's `Host` names an address this server should answer on. /// -/// [`origin_allowed`] only proves Origin and Host *agree*, which a DNS-rebound -/// attacker satisfies trivially: they control both. Rebinding `evil.example` to -/// 127.0.0.1 would otherwise give their page a same-origin position from which -/// to POST `/login` and read the reply. +/// [`origin_allowed`] only proves Origin and Host *agree*, which a +/// DNS-rebound attacker satisfies trivially: they control both. Rebinding +/// `evil.example` to 127.0.0.1 would otherwise give their page a same-origin +/// position from which to POST `/login` and read the reply. /// /// A loopback-bound server can only legitimately be addressed as loopback, so -/// any other Host is refused. When bound off-loopback the operator has taken +/// any other Host is refused. Bound off-loopback, the operator has taken /// responsibility for the network path, and the check would reject legitimate /// proxied hosts, so it does not apply. pub fn host_allowed(head: &RequestHead, bound_loopback: bool) -> bool { @@ -207,9 +206,9 @@ pub fn websocket_handshake( if stream.write_all(handshake.as_bytes()).is_err() { return None; } - // Cap frame and message size. tungstenite's defaults are 16 MiB / 64 MiB, - // which a client could pair with the terminal command queue to park - // gigabytes of pending input. Nothing either server accepts is large. + // Cap frame and message size: tungstenite's defaults (16 MiB / 64 MiB) + // paired with the terminal command queue could park gigabytes of pending + // input. Nothing either server accepts is large. let config = tungstenite::protocol::WebSocketConfig::default() .max_message_size(Some(MAX_WS_MESSAGE_BYTES)) .max_frame_size(Some(MAX_WS_MESSAGE_BYTES)); diff --git a/src/web/common/mod.rs b/src/web/common/mod.rs index be58247a..89852ad1 100644 --- a/src/web/common/mod.rs +++ b/src/web/common/mod.rs @@ -1,8 +1,6 @@ -//! Primitives shared by nightcrow's web servers. -//! -//! Everything here is independent of what a given server actually serves: it -//! knows about passwords, sessions, HTTP framing, and connection accounting, -//! but nothing about screen frames, git data, or terminals. +//! Primitives shared by nightcrow's web servers — passwords, sessions, HTTP +//! framing, connection accounting. Nothing here knows what a server actually +//! serves: no screen frames, git data, or terminals. pub mod auth; pub mod conn; diff --git a/src/web/common/sessions.rs b/src/web/common/sessions.rs index eed96483..4defa6e3 100644 --- a/src/web/common/sessions.rs +++ b/src/web/common/sessions.rs @@ -1,28 +1,18 @@ //! Persistent session token store. Tokens are opaque 256-bit random strings -//! backed by a file so they survive daemon restarts. Each token carries the -//! expiry it was issued with, so a restarted server does not accept stale -//! tokens forever. +//! backed by a file so they survive daemon restarts, each carrying the expiry +//! it was issued with. The lifetime is handed to the store at construction: +//! how long a login lasts is the operator's call, and `None` means tokens +//! never expire on their own. //! -//! The lifetime is the store's, handed to it at construction rather than fixed -//! here: how long a login should last is the operator's call, and a store told -//! `None` issues tokens that never expire on their own. +//! Logout revokes a token server-side — clearing the cookie alone leaves a +//! leaked token usable until expiry. Expired tokens are swept on every write +//! and on load; nothing is scheduled, since the file only changes when someone +//! logs in, logs out, or presents an expired token. //! -//! Logout revokes a token server-side — clearing the cookie alone is not -//! enough because a leaked token would remain usable until expiry. Revocation -//! removes the token from both memory and the on-disk store. -//! -//! Expired tokens are forgotten opportunistically: every write sweeps the whole -//! set, and loading discards what has already run out. Nothing is scheduled — -//! the file only changes when someone logs in, logs out, or presents a token -//! that has expired, and those are the moments worth paying for the sweep. -//! -//! The file is written with owner-only permissions (0o600 on Unix); see -//! `platform::fs`. On Windows the permission call is a no-op (documented -//! there), so operators should place the state directory in a restricted -//! location. -//! -//! When `store_path` is `None` the store is in-memory only, matching the old -//! behaviour for tests and transient runs. +//! The file is written owner-only (0o600 on Unix; see `platform::fs`). On +//! Windows that call is a no-op (documented there), so operators should place +//! the state directory in a restricted location. With `store_path` `None` the +//! store is in-memory only, for tests and transient runs. use crate::platform; use anyhow::{Context, Result, anyhow}; @@ -86,10 +76,9 @@ impl SessionStore { ttl, }; // Write the shortened deadlines down now instead of leaving them to the - // next login. Until they are on disk the file still names the old ones, - // so a second restart would read them and measure a fresh lifetime from - // there — a tightened policy would never take hold on a session that - // restarts more often than the lifetime it is being held to. + // next login: until then the file still names the old ones, so a + // second restart would measure a fresh lifetime from there and a + // tightened policy would never take hold on a fast-restarting session. if shortened { store.persist(); } @@ -120,8 +109,7 @@ impl SessionStore { } /// Invalidate a token server-side. Clearing the cookie alone leaves a - /// leaked token usable until expiry, which makes logout a suggestion - /// rather than a revocation. + /// leaked token usable until expiry. pub fn revoke(&self, token: &str) { { let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); @@ -165,16 +153,8 @@ impl SessionStore { let data = { let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); // Swept here because this is the one place that already holds every - // token and is about to write them down. `is_valid` only reaches the - // token it was asked about, and a session nobody asks about again — - // the browser holding that cookie never came back — would otherwise - // sit in memory and on disk until the daemon restarts, so the file - // would claim sessions that cannot log anyone in. - // - // Ahead of the store having a file: a store without one (the - // fallback when the state directory cannot be opened) holds the same - // tokens in the same map, and is the one that cannot be fixed by a - // restart reading a swept file. + // token and is about to write them down; otherwise a token nobody + // asks about again would sit in memory and on disk until restart. sweep(&mut tokens, SystemTime::now()); serialize(&tokens) }; @@ -202,14 +182,10 @@ fn parse_expiry(field: &str) -> Option { /// Bring loaded expiries down to what the configured lifetime allows. /// -/// Only ever lowers. A token issued under a longer lifetime — or none at all — -/// should not outlive a policy the operator has since tightened, and tightening -/// it is the one edit that has to reach sessions already handed out. A token -/// already closer to running out keeps its own earlier deadline, so a restart -/// never extends anything. -/// -/// Reports whether anything moved, which is what tells the caller the file no -/// longer matches the tokens. +/// Only ever lowers: a token issued under a longer lifetime should not +/// outlive a policy the operator has since tightened, and a token already +/// closer to running out keeps its earlier deadline. Reports whether anything +/// moved, which is what tells the caller the file no longer matches the tokens. fn clamp(tokens: &mut HashMap, ttl: Option, now: SystemTime) -> bool { let Some(ceiling) = ttl.and_then(|ttl| now.checked_add(ttl)) else { return false; diff --git a/src/web/common/sse.rs b/src/web/common/sse.rs index ba3d3d80..d13c7256 100644 --- a/src/web/common/sse.rs +++ b/src/web/common/sse.rs @@ -1,13 +1,11 @@ //! Server-sent events over a plain synchronous writer. //! -//! The ordinary response builder in [`super::http`] always emits a -//! `Content-Length` and `Connection: close`, which ends the connection after -//! one body — the opposite of what a live stream needs. An SSE response -//! instead keeps the socket open and appends events until one side gives up, -//! so it writes its own head and owns the connection from then on. -//! -//! Generic over [`Write`] so the framing is unit-testable against a buffer -//! and the same code drives a real `TcpStream`. +//! [`super::http`]'s response builder always emits `Content-Length` and +//! `Connection: close`, which ends the connection after one body — the +//! opposite of what a live stream needs. An SSE response instead keeps the +//! socket open and appends events until one side gives up, so it writes its +//! own head and owns the connection from then on. Generic over [`Write`] so +//! the framing is unit-testable against a buffer. //! use std::io::{self, Write}; diff --git a/src/web/viewer/assets.rs b/src/web/viewer/assets.rs index cca37841..556f570a 100644 --- a/src/web/viewer/assets.rs +++ b/src/web/viewer/assets.rs @@ -62,17 +62,12 @@ fn plain_host(host: &str) -> bool { /// Serve a built asset, falling back to `index.html` so client-side routes and /// a bare `/` both load the app. /// -/// `host` is the request's `Host` header, which scopes the CSP's socket -/// sources (see [`csp`]). -/// -/// A miss is split by whether the request names a file. An extensionless path is -/// a client-side route and gets the app shell; a path that names a file (has an -/// extension) is a real asset miss and gets a 404. The shell fallback must not -/// cover the second case: handing `index.html` back for a missing `.svg`/`.js` -/// serves HTML under an image or module request, which then fails silently — -/// a stale embedded build made the header/splash crow render as a blank accent -/// tile exactly this way (`/crow-mono.svg` missing → HTML → the `` shows -/// nothing). A loud 404 surfaces the missing asset instead. +/// A miss is split by whether the request names a file. An extensionless path +/// is a client-side route and gets the app shell; a path naming a file is a +/// real asset miss and gets a 404. Handing `index.html` back for a missing +/// `.svg`/`.js` serves HTML under an image or module request, which fails +/// silently — a stale embedded build once rendered the header/splash as a +/// blank accent tile exactly this way. A loud 404 surfaces it instead. pub fn serve(path: &str, host: Option<&str>) -> Option> { let csp = csp(host); let headers = [ @@ -91,10 +86,9 @@ pub fn serve(path: &str, host: Option<&str>) -> Option> { } // `rust_embed` resolves names against the embedded map, so a `..` in the - // request simply misses; there is no filesystem lookup to escape. - // rust-embed carries the guessed type alongside the bytes, so the content - // type comes from the same lookup that found the file — they cannot - // disagree, which matters when the CSP refuses a mistyped script. + // request simply misses; there is no filesystem lookup to escape. The + // guessed mimetype travels with the bytes, so content type and file + // cannot disagree — which matters when the CSP refuses a mistyped script. if let Some(file) = Assets::get(candidate) { return Some(http::response( "200 OK", @@ -130,19 +124,14 @@ const BUILD_META: &str = "nightcrow-build"; /// The app shell, carrying the id of the build it is part of. /// -/// Stamped rather than left to the client to work out, because what the page -/// needs is the build **it** is running, and the only moment that is certain is -/// the one it is handed over. Inferring it from the first API response it -/// happens to get is wrong for a tab that sits on the login screen across a -/// rebuild: the build it adopts is then the new one, and it never learns it is -/// running the old. +/// Stamped rather than left to the client to work out: what the page needs is +/// the build **it** is running, and the only moment that is certain is the one +/// it is handed over. Inferring it from the first API response is wrong for a +/// tab sitting on the login screen across a rebuild. /// -/// The id names the stored file, not these bytes — the stamp is derived from -/// what it is stamped into, so it cannot also be part of it. /// One read, not two: a debug server reads `dist` from disk, so reading the /// bytes and then asking [`build_id`] again could stamp the build that landed -/// in between onto the document that preceded it — a page that would then -/// believe it was current for as long as it stayed open. +/// in between onto the document that preceded it. fn shell(headers: &[(&str, &str)]) -> Option> { let file = Assets::get(SHELL)?; let id = id_of(file.metadata.sha256_hash()); @@ -181,22 +170,18 @@ const BUILD_ID_BYTES: usize = 4; /// Names the built frontend this server is serving. /// /// The hash of `index.html`, because that file names the code: every chunk and -/// stylesheet Vite emits carries a content hash in its filename, so a change to -/// any of them changes a name in the shell. A page can compare what it was -/// served against what the server has now and offer a reload. -/// -/// What that leaves out is `public/`, which is copied under fixed names — an -/// icon or the manifest can change without moving this. Deliberately: what the -/// comparison is for is a page running code the server has replaced, and a file -/// nothing imports cannot put a page in that state. +/// stylesheet Vite emits carries a content hash in its filename, so a change +/// to any of them changes a name in the shell. What that leaves out is +/// `public/`, copied under fixed names — deliberately, since a file nothing +/// imports cannot put a page in the replaced-code state this exists to +/// report. /// /// Read per call rather than held: only a release build embeds `dist`, and a /// debug server reads it from disk — a rebuild under a running daemon is /// exactly the case this exists to report. /// -/// `None` when the shell is missing, which is a build that cannot load at all. -/// Saying nothing is the honest answer there; a placeholder would be a build id -/// that never changes. +/// `None` when the shell is missing: a build that cannot load at all, where +/// saying nothing beats a placeholder id that never changes. pub fn build_id() -> Option { Some(id_of(Assets::get(SHELL)?.metadata.sha256_hash())) } diff --git a/src/web/viewer/clone_jobs.rs b/src/web/viewer/clone_jobs.rs index 21c91bf9..b083f176 100644 --- a/src/web/viewer/clone_jobs.rs +++ b/src/web/viewer/clone_jobs.rs @@ -1,12 +1,11 @@ //! Track in-flight clones so the request that starts one can return at once. //! -//! A clone runs for as long as the remote takes, which is far past what a -//! browser will hold a request open for — and on a phone the tab may be -//! suspended mid-transfer. So `POST /api/clone` starts a thread and answers -//! with an id, and the client polls `GET /api/clone?job=` until the job -//! reaches a terminal state. The thread outlives the request that spawned it: -//! nothing is cancelled by a client that walks away, matching how the -//! terminal hub keeps PTYs alive across disconnects. +//! A clone runs for as long as the remote takes — far past what a browser +//! will hold a request open for, and a phone tab may be suspended +//! mid-transfer. So `POST /api/clone` starts a thread and answers with an id, +//! and the client polls `GET /api/clone?job=` until the job reaches a +//! terminal state. The thread outlives the request that spawned it, matching +//! how the terminal hub keeps PTYs alive across disconnects. use std::collections::HashMap; use std::sync::Mutex; @@ -36,10 +35,10 @@ impl CloneJobs { /// Admit a new job and return its id, or `None` when one is already /// running. /// - /// Admission and insertion happen under one lock on purpose: checking - /// "is anything running?" from the caller and inserting afterwards is a - /// check-then-act race that lets parallel requests each see an idle - /// registry and every one of them spawn a clone. + /// Admission and insertion happen under one lock: checking "is anything + /// running?" and inserting afterwards is a check-then-act race that lets + /// parallel requests each see an idle registry and every one of them + /// spawn a clone. pub fn try_start(&self) -> Option { let mut jobs = self.lock(); if jobs @@ -49,12 +48,10 @@ impl CloneJobs { return None; } let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1; - // Evict before inserting so a long-lived server does not accumulate - // jobs. The *oldest* finished ones go first — dropping every finished - // job at once could take one a client had not read yet, which reads to - // that client as "your clone is gone" even though it succeeded. - // Running jobs are never evicted: their thread still holds the id and - // will write a result to it. + // Evict the *oldest finished* jobs first — dropping all at once could + // take one a client had not read yet, which reads as "your clone is + // gone" even though it succeeded. Running jobs are never evicted: + // their thread still holds the id and will write a result to it. if jobs.len() >= MAX_RETAINED_JOBS { let mut finished: Vec = jobs .iter() @@ -84,10 +81,8 @@ impl CloneJobs { self.lock().get(&id).cloned() } - /// The job currently running, if any. At most one exists by admission, so - /// a client that lost track of its id — a reloaded page, a second tab — - /// can ask what to follow instead of being told a clone is already - /// running with no way to watch it. + /// The job currently running, if any — at most one exists by admission, + /// so a client that lost track of its id can ask what to follow. pub fn running(&self) -> Option { self.lock() .iter() @@ -96,9 +91,9 @@ impl CloneJobs { } fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { - // A poisoned lock means a panic while holding it. The map is plain data - // with no invariant spanning the critical sections, so recovering keeps - // clone tracking usable instead of taking the server down with it. + // Recover from a poisoned lock: the map is plain data with no + // invariant spanning critical sections, and keeping clone tracking + // usable beats taking the server down with it. self.jobs.lock().unwrap_or_else(|err| err.into_inner()) } } diff --git a/src/web/viewer/dto/envelope.rs b/src/web/viewer/dto/envelope.rs index 86893f06..8e004433 100644 --- a/src/web/viewer/dto/envelope.rs +++ b/src/web/viewer/dto/envelope.rs @@ -95,11 +95,9 @@ pub struct HotConfigDto { /// What `GET /api/repos` answers: everything the client needs before it can /// render, in one response. /// -/// Named for what it carries rather than for its route. The route is about -/// repositories — `POST` opens one, `DELETE` closes one — but the `GET` grew -/// into the session's bootstrap, because a client that already polls it every -/// few seconds is the cheapest carrier for anything server-wide it must agree -/// with. +/// The `GET` grew into the session's bootstrap because a client that already +/// polls it every few seconds is the cheapest carrier for anything +/// server-wide it must agree with. /// /// Every field here belongs in `ViewerBootstrap` in `viewer-ui/src/api.ts` too. /// Renaming or retyping one without doing so fails the fixture contract test. @@ -117,19 +115,17 @@ pub struct ViewerBootstrapDto { /// see `prefs::ViewerPrefs`. pub upper_pct: u32, /// Id of the project a client last selected, so a reload lands there - /// instead of on the first tab. `None` when nothing has been selected yet - /// or the remembered project is not currently served. An id, not the path - /// `prefs.rs` stores: clients address repositories by id and never learn - /// the path. + /// instead of on the first tab. An id, not the path `prefs.rs` stores: + /// clients address repositories by id and never learn the path. pub active_repo: Option, /// Which panel each *currently served* project was left maximized in, by - /// id. Projects with no arrangement are absent, as are remembered ones this - /// session is not serving. + /// id. Projects with no arrangement are absent, as are remembered ones + /// this session is not serving. pub maximized: std::collections::HashMap, - /// What each *currently served* project was last showing, by id, so opening - /// one again opens what was open. Absent for a project nothing has been - /// looked at in, and for a remembered project this session is not serving — - /// which keeps its entry on file for when it is. + /// What each *currently served* project was last showing, by id, so + /// opening one again opens what was open. Absent for a project nothing + /// has been looked at in, and for a remembered project this session is + /// not serving — which keeps its entry on file for when it is. pub last_view: std::collections::HashMap, /// This server's wall clock, for dating [`super::ChangedFileDto::mtime`]. pub now_ms: u64, @@ -149,12 +145,11 @@ impl ViewerBootstrapDto { /// useful as facts about the response being built, not as arguments a /// caller could get wrong. /// - /// Takes the whole [`ViewerPrefs`] rather than the fields it needs: several - /// of them are `u32`, and a positional list of those is a pair of arguments - /// a call site can swap with nothing to catch it. `active_repo` stays - /// separate because what goes on the wire is the **id** resolved from - /// `prefs.active_repo`, which only the caller's catalog snapshot can supply. - /// `maximized` and `last_view` are separate for the same reason. + /// Takes the whole [`ViewerPrefs`] rather than the fields it needs: a + /// positional list of `u32`s is a pair of arguments a call site can swap + /// with nothing to catch it. `active_repo`, `maximized`, and `last_view` + /// stay separate because what goes on the wire is the id-resolved form + /// only the caller's catalog snapshot can supply. pub fn new( repos: Vec, hot: HotConfigDto, diff --git a/src/web/viewer/dto/mod.rs b/src/web/viewer/dto/mod.rs index 161d94cd..3dd9b475 100644 --- a/src/web/viewer/dto/mod.rs +++ b/src/web/viewer/dto/mod.rs @@ -1,14 +1,13 @@ //! The viewer's wire format. //! //! Internal git types are never serialized directly — they carry TUI-only -//! fields (`search_lower`, `summary_lower`) and libgit2-shaped types like -//! `Oid`. Every payload below is an explicit whitelist built by hand, so -//! adding a field to an internal struct can never widen what a browser sees, -//! and renaming one breaks the build here instead of silently changing the API. +//! fields and libgit2-shaped types like `Oid`. Every payload is an explicit +//! whitelist built by hand, so adding a field to an internal struct can never +//! widen what a browser sees, and renaming one breaks the build instead of +//! silently changing the API. //! //! [`PROTOCOL_VERSION`] rides on every response so a cached page from an -//! older build can refuse to interpret a newer payload rather than misread -//! it. +//! older build can refuse a newer payload rather than misread it. mod diff; mod envelope; diff --git a/src/web/viewer/dto/status.rs b/src/web/viewer/dto/status.rs index 5e38c7e9..e3a30e44 100644 --- a/src/web/viewer/dto/status.rs +++ b/src/web/viewer/dto/status.rs @@ -53,15 +53,14 @@ pub struct ChangedFileDto { pub worktree: String, /// Worktree mtime as Unix milliseconds, for the client's "recently touched" /// highlight (the same signal the TUI's hot table carries). Absent when the - /// file could not be stat'd — or always, for a commit's file list, where the - /// working tree says nothing about the commit. + /// file could not be stat'd — or always, for a commit's file list, where + /// the working tree says nothing about the commit. /// /// An absolute instant, not an age: the status payload is deduplicated by - /// byteequality before it is pushed, so a field that moved every tick would - /// turn an idle repository into a permanent event stream. Because the - /// instant comes from this machine's clock and the browser may be running on - /// another device, the client corrects for the difference using the - /// `now_ms` that rides the repo poll (see [`server_now_millis`]). + /// byte-equality before it is pushed, so a field that moved every tick + /// would turn an idle repository into a permanent event stream. The client + /// corrects for clock skew against the `now_ms` riding the repo poll (see + /// [`server_now_millis`]). #[serde(skip_serializing_if = "Option::is_none")] pub mtime: Option, } @@ -103,12 +102,9 @@ fn unix_millis(t: SystemTime) -> Option { .map(|d| d.as_millis() as u64) } -/// The server's wall clock in Unix milliseconds — the reference the client dates -/// `mtime` against. `0` for a pre-epoch clock, which leaves the client on its own -/// clock rather than shifting it by a nonsense offset. -/// -/// Sent because `mtime` is an absolute instant produced by *this* machine while -/// the browser reading it may be another device entirely (see [`ChangedFile`]). +/// The server's wall clock in Unix milliseconds — the reference the client +/// dates `mtime` against. `0` for a pre-epoch clock, which leaves the client +/// on its own clock rather than shifting it by a nonsense offset. pub fn server_now_millis() -> u64 { unix_millis(SystemTime::now()).unwrap_or(0) } diff --git a/src/web/viewer/highlight.rs b/src/web/viewer/highlight.rs index 61339887..cfeee204 100644 --- a/src/web/viewer/highlight.rs +++ b/src/web/viewer/highlight.rs @@ -1,9 +1,9 @@ //! Server-side syntax highlighting for the viewer. //! -//! Reuses `syntect` + `two-face` — already dependencies, and the exact way the -//! TUI highlights — so the browser needs no highlighter of its own and the -//! colours match the terminal UI. Highlighting runs on the request thread; the -//! diff and file byte ceilings in [`super::limits`] bound the work. +//! Reuses `syntect` + `two-face` — already dependencies and the exact way the +//! TUI highlights — so the browser needs no highlighter and the colours match +//! the terminal UI. Runs on the request thread; the byte ceilings in +//! [`super::limits`] bound the work. use crate::web::viewer::dto::SpanDto; use std::sync::OnceLock; diff --git a/src/web/viewer/limits.rs b/src/web/viewer/limits.rs index 216df25b..6db9868c 100644 --- a/src/web/viewer/limits.rs +++ b/src/web/viewer/limits.rs @@ -8,12 +8,11 @@ /// Commits returned by one page of `/api/log`. Matches the TUI's /// `commit_log_page_size` default. pub const MAX_LOG_PAGE: usize = 100; -// `/api/log?skip=` deliberately has no ceiling. A ceiling here would look -// prudent and protect nothing: the skip feeds `Iterator::skip` on a revwalk, so -// a request walks at most `skip + page` or the whole history, whichever is -// smaller. An absurd skip costs what walking the repository costs and no more, -// while a ceiling would turn the deep end of a long history into a page the -// client can see exists and can never fetch. +// `/api/log?skip=` deliberately has no ceiling: the skip feeds +// `Iterator::skip` on a revwalk, so a request walks at most `skip + page` or +// the whole history, whichever is smaller — an absurd skip costs what walking +// the repository costs and no more, while a ceiling would make the deep end +// of a long history a page the client can see exists and can never fetch. /// Changed paths returned while drilling into one commit. pub const MAX_COMMIT_FILES: usize = 2_000; /// Entries returned for one directory level of `/api/tree`. @@ -30,14 +29,13 @@ pub const MAX_TREE_SEARCH_QUERY_BYTES: usize = 256; pub const MAX_STATUS_FILES: usize = 2_000; /// Bytes of diff text returned for one file. pub const MAX_DIFF_BYTES: usize = 1024 * 1024; -/// Lines of diff returned for one file, whichever ceiling is hit first. +/// Lines of diff returned for one file — whichever ceiling is hit first. pub const MAX_DIFF_LINES: usize = 20_000; /// Bytes of a single SSE payload. Status is conflated to the latest value, so /// this bounds one snapshot, not a backlog. pub const MAX_SSE_PAYLOAD_BYTES: usize = 1024 * 1024; -/// Live connections the viewer's accept loop will hold. Each one costs a -/// thread, so without a ceiling anything that can reach the port can exhaust -/// the process. +/// Live connections the viewer's accept loop will hold — each one costs a +/// thread. pub const MAX_VIEWER_CONNECTIONS: usize = 64; /// A list that may have been cut short, with the fact recorded. @@ -59,10 +57,9 @@ impl Capped { } } -/// Cut `text` to at most `max_bytes`, never splitting a UTF-8 character. The -/// cut walks back to the nearest boundary so a multi-byte character -/// straddling the limit is dropped whole rather than emitted as a broken -/// fragment. +/// Cut `text` to at most `max_bytes`, never splitting a UTF-8 character: the +/// cut walks back to the nearest boundary so a straddling multi-byte +/// character is dropped whole rather than emitted as a broken fragment. pub fn cap_text(text: &str, max_bytes: usize) -> (String, bool) { if text.len() <= max_bytes { return (text.to_string(), false); diff --git a/src/web/viewer/mod.rs b/src/web/viewer/mod.rs index 98f8db4f..c516763a 100644 --- a/src/web/viewer/mod.rs +++ b/src/web/viewer/mod.rs @@ -1,6 +1,6 @@ -//! Web viewer: a native browser UI for the git panel and terminals, served as -//! its own HTTP server, independent of the TUI. Nothing here touches `App`, -//! `ui`, or `input`, which lets the server run headless (`nightcrow serve`). +//! Web viewer: a browser UI for the git panel and terminals, served as its +//! own HTTP server. Nothing here touches `App`, `ui`, or `input`, which lets +//! the server run headless (`nightcrow serve`). pub mod assets; pub mod clone_jobs; diff --git a/src/web/viewer/server/clone_routes.rs b/src/web/viewer/server/clone_routes.rs index d1dbd11f..bd0d6d25 100644 --- a/src/web/viewer/server/clone_routes.rs +++ b/src/web/viewer/server/clone_routes.rs @@ -76,8 +76,8 @@ pub(super) fn handle_clone(body: &str, state: &Arc) -> Vec { } let worker = Arc::clone(state); - // The closure takes the path, so keep one for the spawn-failure branch — - // the claimed directory must be released or it blocks a retry. + // Keep one path for the spawn-failure branch: the claimed directory must + // be released or it blocks a retry. let claimed = dest.clone(); if let Err(err) = std::thread::Builder::new() .name("nightcrow-viewer-clone".to_string()) @@ -100,18 +100,14 @@ fn run_and_record(state: &ViewerState, id: u64, url: &str, dest: PathBuf) { Ok(()) => CloneState::Done(crate::platform::paths::for_display(&dest).into_owned()), Err(err) => { // The destination was created here, so a failed clone would leave - // a directory behind that blocks a retry under the same name. - // Non-recursive on purpose: it cannot destroy content if - // something else has taken this path in the meantime. That means - // a failure git does not clean up after — it keeps the repository - // when only the checkout fails — leaves the directory in place. - // A visible leftover the user can delete beats deleting files - // that turned out not to be ours. + // a directory behind that blocks a retry. Non-recursive on + // purpose: it cannot destroy content if something else has taken + // this path in the meantime. A visible leftover the user can + // delete beats deleting files that turned out not to be ours. let _ = std::fs::remove_dir(&dest); - // git's message names the real problem ("repository not found", - // "permission denied"), which is exactly what the user must act on. - // It is the remote's words about a URL the user typed, not server - // internals, so it is shown rather than redacted. + // git's message names the real problem and is the remote's words + // about a URL the user typed, not server internals — shown, not + // redacted. tracing::info!(error = %err, "clone failed"); CloneState::Failed(err.to_string()) } @@ -124,9 +120,7 @@ fn run_and_record(state: &ViewerState, id: u64, url: &str, dest: PathBuf) { /// /// With no id the question is instead "what is running?", which is what a page /// that just loaded asks: the clone it should be following may have been -/// started by a tab that has since been reloaded or closed, and without this -/// that client could only see the 409 refusing a second clone, never the job -/// causing it. +/// started by a tab that has since been reloaded or closed. pub(super) fn handle_clone_status(head: &RequestHead, state: &ViewerState) -> Vec { let Some(raw) = head.query_param("job") else { return encode(serde_json::json!({ "job": state.clones.running() })); diff --git a/src/web/viewer/server/dispatch.rs b/src/web/viewer/server/dispatch.rs index 0a19f478..cda4676f 100644 --- a/src/web/viewer/server/dispatch.rs +++ b/src/web/viewer/server/dispatch.rs @@ -64,12 +64,11 @@ fn handle_connection(mut stream: TcpStream, state: Arc) { return; } ("GET", "/logout") => { - // A real logout is a top-level navigation (the header link, so - // `Sec-Fetch-Dest: document`). A framed request here is something - // embedded — the HTML preview's sandboxed frame navigating itself — - // trying to end the session out from under the person. Refuse it; - // absent metadata (an old client) still logs out, which is the - // safe direction for a control the person meant to reach. + // A real logout is a top-level navigation (the header link). A + // framed request here is something embedded — the HTML preview's + // sandboxed frame navigating itself — trying to end the session + // out from under the person. Refuse it; absent metadata (an old + // client) still logs out, which is the safe direction. if head.header("sec-fetch-dest") == Some("iframe") { let _ = stream.write_all(&text_response("403 Forbidden", "not from this context")); return; @@ -170,11 +169,10 @@ fn handle_connection(mut stream: TcpStream, state: Arc) { return; } - // Re-reading config.toml. POST for the same CSRF reasoning as the others, and - // the body is ignored: what is read is the file on this machine's disk, so this - // cannot be used to hand the session a configuration of the caller's own - // making. An authenticated user can already open a shell here, so re-reading a - // file they wrote stays within the same trust boundary. + // Re-reading config.toml. POST for the same CSRF reasoning as the others, + // and the body is ignored: what is read is the file on this machine's + // disk, so this cannot hand the session a configuration of the caller's + // own making. if head.method == "POST" && head.path == "/api/reload" { let _ = stream.write_all(&handle_reload_config(&state)); return; diff --git a/src/web/viewer/server/handlers/http.rs b/src/web/viewer/server/handlers/http.rs index c58cdd92..6774c4ea 100644 --- a/src/web/viewer/server/handlers/http.rs +++ b/src/web/viewer/server/handlers/http.rs @@ -25,7 +25,8 @@ pub(in crate::web::viewer::server) fn optional_oid( } /// A non-negative count query parameter, defaulting to zero when absent. -/// Deliberately unbounded -- see the note beside [`crate::web::viewer::limits::MAX_LOG_PAGE`]. +/// Deliberately unbounded — see the note beside +/// [`crate::web::viewer::limits::MAX_LOG_PAGE`]. pub(in crate::web::viewer::server) fn optional_count( head: &crate::web::common::http::RequestHead, name: &str, diff --git a/src/web/viewer/server/handlers/repository.rs b/src/web/viewer/server/handlers/repository.rs index 34726635..e141e223 100644 --- a/src/web/viewer/server/handlers/repository.rs +++ b/src/web/viewer/server/handlers/repository.rs @@ -6,9 +6,8 @@ use crate::session::catalog::RepoEntry; use anyhow::{Context, Result}; /// Look the repository up, validate any `path` parameter, then run `body`. -/// -/// Validation happens here rather than in each handler so no route can forget -/// it. A traversal path is refused uniformly, and never echoed back. +/// Validation happens here rather than in each handler so no route can +/// forget it; a traversal path is refused uniformly and never echoed back. pub(in crate::web::viewer::server) fn with_repo( head: &crate::web::common::http::RequestHead, state: &ViewerState, @@ -39,12 +38,11 @@ pub(in crate::web::viewer::server) fn with_repo( /// stricter [`with_repo`] adds refusing symlinks and requiring the path to /// exist, which are what protect a file this process is about to open; git /// reads a symlink as a blob holding the target's name, never the target's -/// contents, and a path that is gone is exactly what a deletion diff is about. -/// -/// Named for what the path is *for* rather than where it came from. A commit's -/// file and a deleted worktree file need the same rule for the same reason — -/// neither is on disk to be resolved — and calling it "commit" sent the second -/// one to the gate that turned it into a 400. +/// contents, and a path that is gone is exactly what a deletion diff is +/// about. Named for what the path is *for* rather than where it came from: a +/// commit's file and a deleted worktree file need the same rule for the same +/// reason — neither is on disk to be resolved — and calling it "commit" sent +/// the second one to the gate that turned it into a 400. pub(in crate::web::viewer::server) fn with_repo_git_path( head: &crate::web::common::http::RequestHead, state: &ViewerState, diff --git a/src/web/viewer/server/handlers/sse.rs b/src/web/viewer/server/handlers/sse.rs index 5e7fb7ff..c834d915 100644 --- a/src/web/viewer/server/handlers/sse.rs +++ b/src/web/viewer/server/handlers/sse.rs @@ -31,8 +31,8 @@ pub(in crate::web::viewer::server) fn serve_events( break; } } - // Nothing changed: prove the socket is still alive. This is the - // only way a closed tab is discovered. + // Nothing changed: prove the socket is still alive — the only way + // a closed tab is discovered. None => { if sse.heartbeat().is_err() { break; diff --git a/src/web/viewer/server/handlers/terminal.rs b/src/web/viewer/server/handlers/terminal.rs index 760fa501..be7ed936 100644 --- a/src/web/viewer/server/handlers/terminal.rs +++ b/src/web/viewer/server/handlers/terminal.rs @@ -15,13 +15,10 @@ const MAX_VIEWER_ID: usize = 64; /// itself. /// /// The page generates this once per tab and sends it on every socket, so its -/// connections can come and go without the session reading them as somebody new -/// sitting down. -/// -/// A boundary input, so it is held to what an id can be: a short run of plain -/// characters. An id that is missing or malformed gets one of its own rather -/// than a refusal -- the page still works, it simply behaves as it did before it -/// could name itself. +/// connections can come and go without the session reading them as somebody +/// new sitting down. A missing or malformed id gets one of its own rather +/// than a refusal — the page still works, it simply behaves as it did before +/// it could name itself. fn browser_viewer(head: &crate::web::common::http::RequestHead) -> ViewerId { let named = head.query_param("viewer").filter(|id| { !id.is_empty() @@ -47,18 +44,12 @@ fn anonymous_viewer() -> String { /// Whether a failed socket operation leaves the connection usable. /// -/// A timeout is not a departure. Both directions carry one -- reads poll at -/// [`TERM_POLL_TIMEOUT`] so the loop can service the other side, writes get -/// [`SSE_HEARTBEAT`] so a stalled reader cannot wedge this thread forever -- and -/// each surfaces as `WouldBlock` on macOS and `TimedOut` on Linux. -/// -/// The write side counting that as fatal is what made the panel rebuild itself -/// out of nowhere: a page that stopped reading for fifteen seconds -- a phone -/// asleep, a tunnel renegotiating, a handover between networks -- had its socket -/// closed under it, reconnected, and replayed every pane's history from scratch. -/// tungstenite draws the line in the same place: an `Io` error is fatal "except -/// for WouldBlock", and the frame that could not go out is held in its write -/// buffer for the next `write` or `flush` to finish. +/// A timeout is not a departure: it surfaces as `WouldBlock` on macOS and +/// `TimedOut` on Linux, and ending the connection there cost a page that +/// stopped reading for fifteen seconds (a phone asleep, a tunnel +/// renegotiating) every pane replayed from scratch. tungstenite draws the +/// same line: an `Io` error is fatal "except for WouldBlock", and the frame +/// that could not go out stays in its write buffer for the next flush. /// /// A client that has genuinely stopped keeping up is still cut off — by the /// hub, once its queue fills (`broadcast_locked`). That is where the cap @@ -113,22 +104,20 @@ pub(in crate::web::viewer::server) fn serve_terminal( return; }; // `claim` is the page saying a person just opened it, as opposed to a - // repository switch or a reconnect. Absent means no -- a socket that does not - // say it arrived must not take the sizing off whoever is looking. + // repository switch or a reconnect. Absent means no — a socket that does + // not say it arrived must not take the sizing off whoever is looking. let arriving = head.query_param("claim").as_deref() == Some("1"); // A second handle, kept by the hub only to end this connection if the page - // stops draining its queue: the loop below is then parked in `ws.read()` and - // nothing else would wake it. A clone that could not be made costs the hub - // that ability and nothing else. + // stops draining its queue: the loop below is then parked in `ws.read()` + // and nothing else would wake it. A clone that could not be made costs the + // hub that ability and nothing else. let evict_handle = match evict_handle { Ok(handle) => Some(handle), Err(err) => { - // Degrades to what this did before there was a handle at all: the - // client is dropped from the broadcast list but its socket stays - // open. Logged rather than passed over, because the page then holds - // a panel that has stopped updating and nothing else says so -- and - // because a clone that fails means descriptors are exhausted, which - // is worth knowing on its own. + // Degrades to no handle: the client is dropped from the broadcast + // list but its socket stays open. Logged because the page then + // holds a panel that has stopped updating and nothing else says + // so — and a failed clone means descriptors are exhausted. tracing::warn!(%err, "viewer: a terminal socket cannot be cut off if it stalls"); None } @@ -164,8 +153,8 @@ pub(in crate::web::viewer::server) fn serve_terminal( Ok(()) => unflushed = false, // Stop pulling from the hub while the socket will not take it, // so what is still queued backs up where the cap is: the hub's - // own queue, whose overflow is what disconnects a client that - // has really stopped keeping up. + // own queue, whose overflow disconnects a client that has + // really stopped keeping up. Err(err) if stalled_not_gone(&err) => { unflushed = true; break; diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index 6f87357e..31941b63 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -1,8 +1,8 @@ //! The viewer's HTTP server: authenticated routes over a shared session. //! -//! Request handling order is Host/Origin, static assets, authentication, -//! repository lookup, then path validation. Git and I/O details are redacted -//! before responses because they can contain absolute server paths. +//! Request order is Host/Origin, static assets, authentication, repository +//! lookup, then path validation. Git and I/O details are redacted from +//! responses because they can contain absolute server paths. mod clone_routes; mod dispatch; diff --git a/src/web/viewer/server/mutations/filesystem.rs b/src/web/viewer/server/mutations/filesystem.rs index 02df491a..f4ae0e83 100644 --- a/src/web/viewer/server/mutations/filesystem.rs +++ b/src/web/viewer/server/mutations/filesystem.rs @@ -5,17 +5,17 @@ use super::lookup::redact; struct MkdirRequest { /// The directory to create the new folder inside. path: String, - /// The new folder's name. Must be a single plain path segment. + /// Must be a single plain path segment. name: String, } /// Create a new folder inside a directory the picker is browsing. /// -/// The parent is confined only as much as `browse` is, but `name` is held to a -/// single plain segment: separators, `..`, a leading `.` (which also rules out -/// `.git`), and NUL are all rejected. Combined with canonicalizing the parent -/// first, the created folder can only ever land directly under the browsed -/// directory. +/// The parent is confined only as much as `browse` is, but `name` is held to +/// a single plain segment: separators, `..`, a leading `.` (which also rules +/// out `.git`), and NUL are all rejected. Combined with canonicalizing the +/// parent first, the created folder can only ever land directly under the +/// browsed directory. pub(in crate::web::viewer::server) fn handle_mkdir(body: &str) -> Vec { let request: MkdirRequest = match serde_json::from_str(body) { Ok(request) => request, diff --git a/src/web/viewer/server/mutations/lookup.rs b/src/web/viewer/server/mutations/lookup.rs index 1890916d..080338fd 100644 --- a/src/web/viewer/server/mutations/lookup.rs +++ b/src/web/viewer/server/mutations/lookup.rs @@ -20,8 +20,8 @@ pub(in crate::web::viewer::server) fn lookup_repo( .ok_or_else(|| json_error("404 Not Found", "unknown repository")) } -/// Map an internal error to a fixed public message, logging the detail. -/// Git and I/O errors may name absolute paths, symlink targets, and file sizes. +/// Map an internal error to a fixed public message, logging the detail: git +/// and I/O errors may name absolute paths, symlink targets, and file sizes. pub(in crate::web::viewer::server) fn redact(context: &str, err: &anyhow::Error) -> Vec { tracing::debug!(%err, context, "viewer: request failed"); json_error("400 Bad Request", "request could not be served") diff --git a/src/web/viewer/server/mutations/preferences.rs b/src/web/viewer/server/mutations/preferences.rs index 1ef6193f..038ba808 100644 --- a/src/web/viewer/server/mutations/preferences.rs +++ b/src/web/viewer/server/mutations/preferences.rs @@ -115,9 +115,8 @@ pub(in crate::web::viewer::server) fn handle_set_prefs(body: &str, state: &Viewe }; // The project in front is shared, so a write that changes it re-points - // every open page — and two pages tugging it back and forth would show - // here as alternating switches. No-op writes (a page confirming what is - // already in front) stay silent. + // every open page — two pages tugging it back and forth would show here + // as alternating switches. No-op writes stay silent. if let Some(path) = &active_path { let before = state.session.prefs().get().active_repo; if before.as_deref() != Some(path.as_str()) { @@ -160,15 +159,13 @@ pub(in crate::web::viewer::server) fn handle_set_prefs(body: &str, state: &Viewe ) } -/// Turn a client's view into the form the prefs file keeps: its repo id becomes -/// the path that file is keyed by, and the names it carries have to be ones this -/// build knows. +/// Turn a client's view into the form the prefs file keeps: its repo id +/// becomes the path that file is keyed by, and the names it carries have to +/// be ones this build knows. /// -/// Paths are not checked here. They are checked where they are stored -/// (`prefs::repo_view`), which is the door the file itself also comes through — -/// a second check here would be a second place for the rule to drift. What is -/// answered instead is what the store cannot express: a project that is not -/// served, and a tab or a face this build has no name for. +/// Paths are not checked here — they are checked where they are stored +/// (`prefs::repo_view`), the door the file itself also comes through; a +/// second check here would be a second place for the rule to drift. fn resolve_view(request: ViewRequest, state: &ViewerState) -> Result> { let Some(entry) = state.session.catalog().get(&request.repo) else { return Err(json_error("400 Bad Request", "unknown repo")); diff --git a/src/web/viewer/server/mutations/reload.rs b/src/web/viewer/server/mutations/reload.rs index 0377ea75..f0abea1a 100644 --- a/src/web/viewer/server/mutations/reload.rs +++ b/src/web/viewer/server/mutations/reload.rs @@ -1,12 +1,10 @@ use super::super::ViewerState; use super::super::http_util::json_error; -/// Re-read `config.toml` and report what was applied. -/// -/// The body is ignored and no configuration is accepted from the request: the -/// file on the server's disk is what is read. Deciding who may ask happened -/// before this — the route is behind the same session cookie as every other -/// mutation. +/// Re-read `config.toml` and report what was applied. The body is ignored — +/// the file on the server's disk is what is read, never anything from the +/// request. Deciding who may ask happened before this: the route sits behind +/// the same session cookie as every other mutation. pub(in crate::web::viewer::server) fn handle_reload_config(state: &ViewerState) -> Vec { match crate::session::reload::reload_config(state.session()) { Ok(report) => super::encode_response( diff --git a/src/web/viewer/server/mutations/repository.rs b/src/web/viewer/server/mutations/repository.rs index c6bfab5f..c83a0e7a 100644 --- a/src/web/viewer/server/mutations/repository.rs +++ b/src/web/viewer/server/mutations/repository.rs @@ -13,9 +13,8 @@ struct ReorderRequest { order: Vec, } -/// Open a repository from the browser and add it to the served catalog. -/// -/// The path is user-supplied but the response is public, so a bad path yields a +/// Open a repository from the browser and add it to the served catalog. The +/// path is user-supplied but the response is public, so a bad path yields a /// generic message. pub(in crate::web::viewer::server) fn handle_open_repo(body: &str, state: &ViewerState) -> Vec { let request: OpenRequest = match serde_json::from_str(body) { diff --git a/src/web/viewer/server/preview.rs b/src/web/viewer/server/preview.rs index 97d7ced1..a9907de8 100644 --- a/src/web/viewer/server/preview.rs +++ b/src/web/viewer/server/preview.rs @@ -1,56 +1,35 @@ //! The HTML preview document: the one API response that is a repository file //! served as itself. //! -//! The file pane used to inline the file into a `srcdoc` frame, but a -//! local-scheme document inherits the embedder's CSP, whose `script-src -//! 'self'` refuses the inline scripts a self-contained page is made of — an -//! HTML slide deck rendered but never ran. Only a network response carries a -//! policy of its own, which is what this endpoint exists to attach. +//! A `srcdoc` frame inherits the embedder's CSP, whose `script-src 'self'` +//! refuses the inline scripts a self-contained page is made of — an HTML slide +//! deck rendered but never ran. Only a network response carries a policy of +//! its own, which is what this endpoint exists to attach. //! -//! What that policy opens, and what it keeps shut: +//! The policy's shape: `sandbox allow-scripts` gives the document an opaque +//! origin (no cookies, no app DOM/storage; its requests arrive unauthenticated +//! with `Origin: null`, which `origin_allowed` refuses before auth is even +//! consulted). `script-src 'unsafe-inline'` is the point of the endpoint, with +//! no host source beside it; `connect-src 'none'` closes fetch and WebSocket +//! outright; `frame-ancestors 'self'` keeps other origins from embedding it. +//! The iframe's own `sandbox="allow-scripts"` attribute intersects with the +//! header, so either one failing still leaves the other standing. //! -//! - **`sandbox allow-scripts`** gives the document an opaque origin even -//! though its URL is this server's. Scripts run, but the document is -//! nobody: no cookie jar, nothing of the app's DOM or storage, and every -//! request it makes arrives unauthenticated (`SameSite=Strict`) with -//! `Origin: null` — which `origin_allowed` refuses before auth is even -//! consulted, the terminal WebSocket included. -//! - **`script-src 'unsafe-inline'`** is the point of the endpoint: inline -//! scripts run. No host source stands beside it, so no script is fetched -//! from anywhere to run. -//! - **`connect-src 'none'`** closes fetch and WebSocket outright, so the -//! frame cannot phone any host — this server included. Subresources are -//! `data:` or refused (`default-src 'none'`), keeping the standing rule -//! that a preview never loads from another host. -//! - **`frame-ancestors 'self'`** keeps other origins from embedding it. -//! -//! The iframe that loads this keeps its own `sandbox="allow-scripts"` -//! attribute too: header and attribute intersect, so either one failing an -//! old browser or a future edit still leaves the other standing. -//! -//! One more belt for one more brace: a *top-level* navigation to this URL — a -//! pasted link, not an embed — is served the file as inert `text/plain`. This -//! closes the case a browser that ignored the CSP `sandbox` (none in a decade, -//! but the header is our only wall against it) would otherwise open: a -//! repository file executed as a *first-party* document with the session -//! cookie. The signal is `Sec-Fetch-Dest: document`, set by the browser on a -//! top-level navigation and unforgeable from script. -//! -//! It fails *open*: a request that carries no Fetch metadata is treated as an -//! embed and gets the executable document. That is deliberate, because browsers -//! send `Sec-Fetch` only from a potentially-trustworthy origin (HTTPS or -//! localhost) — so every plain-HTTP origin omits it, and the viewer reached -//! over a LAN or Tailscale address is exactly that. Failing closed there served -//! the raw source instead of the page on the whole mobile path. On that path -//! the CSP `sandbox` header stands alone — as it already does everywhere; this -//! gate only ever added a second wall where the metadata exists to raise it. +//! A *top-level* navigation to this URL — a pasted link — is served the file +//! as inert `text/plain`, signalled by the unforgeable `Sec-Fetch-Dest: +//! document`. Otherwise a browser ignoring CSP `sandbox` would execute a +//! repository file as a *first-party* document with the session cookie. It +//! fails *open*: browsers send `Sec-Fetch` only from a potentially-trustworthy +//! origin, so every plain-HTTP origin omits it and the viewer reached over a +//! LAN or Tailscale address is exactly that — failing closed there broke the +//! whole mobile path. On that path the CSP `sandbox` header stands alone, as +//! it already does everywhere. //! //! What no policy here closes: a script may navigate its own frame away — to -//! an external URL (carrying its own source, which its author already has) or -//! to a phishing page in the pane. That is inherent to allowing scripts, is -//! recorded as an accepted residual in `docs/architecture/web.md`, and is why -//! the boundary this file defends is "the frame cannot reach the *session*", -//! not "the frame cannot emit anything". +//! an external URL or a phishing page in the pane. That is inherent to +//! allowing scripts, is recorded as an accepted residual in +//! `docs/architecture/web.md`, and is why the boundary this file defends is +//! "the frame cannot reach the *session*", not "the frame cannot emit anything". /// See the module doc for why each directive is what it is. const PREVIEW_CSP: &str = "sandbox allow-scripts; \ @@ -92,9 +71,8 @@ pub(super) fn route( }) } -/// The file as `text/plain`, for an explicit top-level navigation. Nothing -/// executes: a browser that reached this by a top-level navigation sees the -/// source, not a first-party page running with the session's cookie. +/// The file as `text/plain` for an explicit top-level navigation. Nothing +/// executes: no first-party page running with the session's cookie. fn inert_response(source: &str) -> Vec { crate::web::common::http::response( "200 OK", diff --git a/src/web/viewer/server/routes.rs b/src/web/viewer/server/routes.rs index be5e3347..79b7ab09 100644 --- a/src/web/viewer/server/routes.rs +++ b/src/web/viewer/server/routes.rs @@ -22,8 +22,7 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { // Everything server-wide the client must agree with rides this one // response rather than getting endpoints of its own: the client // already polls it every few seconds, so a setting changed here - // reaches every device within one interval, and `/api/status` — - // a hot, deduplicated stream — stays free of configuration. + // reaches every device within one interval. let prefs = state.session.prefs().get(); // The remembered project is resolved to an id per response rather // than stored as one, and from the same snapshot as the list it @@ -60,13 +59,10 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { } "/api/status" => with_repo(head, state, |entry| { // Served from the runtime's latest snapshot rather than a fresh git - // call *while it is watching*: it is already reading the tree every + // call while it is watching: the watch already reads the tree every // second, and this keeps a page refresh from queueing another walk. - // - // While nothing is subscribed the watch is off, and `latest` is - // whatever was true when the last client left — so this reads once - // rather than answering with it. That is the same walk the watch - // would have done, paid only when someone asks. + // With nothing subscribed the watch is off and `latest` is whatever + // was true when the last client left — so this reads once instead. if !entry.runtime.is_watching() { entry.runtime.refresh_now(); } @@ -156,28 +152,27 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { "/api/preview" => super::preview::route(head, state), "/api/log" => with_repo(head, state, |entry| { let repo = open_repo(&entry.path)?; - // `from` pins the walk so a page fetched later continues the history - // the earlier pages described, even if commits landed meanwhile — - // and a terminal that commits sits right below this list. Absent on - // the first request, which is what establishes the anchor. + // `from` pins the walk so a page fetched later continues the + // history the earlier pages described, even if commits landed + // meanwhile — and a terminal that commits sits right below this + // list. Resolved once, and the walk is then given exactly this + // oid: asking the loader to fall back to HEAD itself would read + // the ref a second time, and a first commit landing between the + // two reads would return commits under an anchor of `None`, + // which the client reads as the end of the history. let skip = optional_count(head, "skip")?; - // Resolved once, and the walk is then given exactly this oid. Asking - // the loader to fall back to HEAD itself would read the ref a second - // time, and a first commit landing between the two reads would - // return commits under an anchor of `None` — which the client reads - // as the end of the history. let anchor = match optional_oid(head, "from")? { Some(oid) => Some(oid), None => diff::head_commit_oid(&repo)?, }; let commits = match anchor { - // One more than a page, so a full page can be told apart from a - // page that happens to end at the last commit. + // One more than a page, so a full page can be told apart from + // a page that happens to end at the last commit. Some(oid) => { diff::load_commit_log_from(&repo, Some(oid), skip, limits::MAX_LOG_PAGE + 1)? } - // No commit to walk from: an unborn HEAD, which is a repository - // with no history rather than an error. + // No commit to walk from: an unborn HEAD, which is a + // repository with no history rather than an error. None => Vec::new(), }; Ok(json_response( @@ -233,10 +228,9 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { } /// List the server sub-directories under `path` (home when absent) for the -/// folder picker. Directories only, hidden ones skipped; each is flagged when -/// it looks like a git worktree. Deliberately unconfined — the picker browses -/// the server to find a repo to open — but reachable only authenticated and at -/// the same trust as the terminal. +/// folder picker. Directories only, hidden ones skipped. Deliberately +/// unconfined — the picker browses the server to find a repo to open — but +/// reachable only authenticated and at the same trust as the terminal. fn browse(head: &RequestHead) -> Vec { let start = match head.query_param("path").filter(|p| !p.is_empty()) { Some(path) => std::path::PathBuf::from(path), diff --git a/src/workspace/accent.rs b/src/workspace/accent.rs index f49decb2..0376addc 100644 --- a/src/workspace/accent.rs +++ b/src/workspace/accent.rs @@ -1,8 +1,7 @@ //! The colour this client paints the session in. //! -//! Read rather than decided: the session owns the accent (see the shared side of -//! the boundary in `docs/architecture.md`), so everything here either adopts what -//! the daemon reported or works out what to ask for next. +//! The session owns the accent (see `docs/architecture.md`), so this module +//! only adopts what the daemon reported or works out what to ask for next. use super::Workspace; @@ -13,9 +12,10 @@ impl Workspace { self.accent_idx = idx % crate::config::Accent::ALL.len(); } - /// The index the next ` p` asks for. Derived here rather than by the - /// daemon so the request names a colour instead of a step — two clients - /// cycling at once would otherwise land somewhere neither asked for. + /// The index the next ` p` asks for. Derived here rather than by + /// the daemon so the request names a colour instead of a step — two + /// clients cycling at once would otherwise land somewhere neither asked + /// for. pub fn next_accent_index(&self) -> usize { (self.accent_idx + 1) % crate::config::Accent::ALL.len() } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index fa61eef3..d3ce0660 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -1,8 +1,7 @@ //! Per-repo state (`App`) held in a list, with the active tab index on top. //! Closing a tab drops the `App`, which tears down its worker and panes — no //! field-by-field reset to keep in sync. The list may be empty, so `active()` -//! yields an `Option` and the open-repo dialog lives here rather than on a -//! project. +//! yields an `Option`. mod accent; mod path_complete; @@ -82,7 +81,7 @@ impl Workspace { into.remember(&entry.repo, entry.state.clone()); } for project in self.projects.iter().rev() { - into.remember(&project.repo_path, project.session_to_save()); + into.remember(project.repository_path(), project.session_to_save()); } into.sessions } @@ -125,7 +124,7 @@ impl Workspace { } /// The active project and the dialog together, borrowed from disjoint - /// fields so a frame can render both without a borrow-checker conflict. + /// fields so a frame can render both. pub fn render_parts(&mut self) -> (Option<&mut App>, &RepoInput) { (self.projects.get_mut(self.active), &self.repo_input) } @@ -213,11 +212,12 @@ impl Workspace { // Carry the closing project's view state, or reopening would restore // the last-shutdown snapshot instead. let closing = self.projects.remove(index); - self.remembered.retain(|s| s.repo != closing.repo_path); + self.remembered + .retain(|s| s.repo != closing.repository_path()); self.remembered.insert( 0, RepoSession { - repo: closing.repo_path.clone(), + repo: closing.repository_path().to_string(), state: closing.session_to_save(), }, ); @@ -236,10 +236,13 @@ impl Workspace { /// active. Paths not open are skipped and open tabs the order does not name /// keep their relative position at the end. pub fn reorder_to(&mut self, order: &[&str]) { - let active_path = self.projects.get(self.active).map(|p| p.repo_path.clone()); + let active_path = self + .projects + .get(self.active) + .map(|project| project.repository_path().to_string()); let mut arranged: Vec = Vec::with_capacity(self.projects.len()); for path in order { - if let Some(index) = self.projects.iter().position(|p| p.repo_path == *path) { + if let Some(index) = self.index_of_repo(path) { arranged.push(self.projects.remove(index)); } } @@ -253,8 +256,12 @@ impl Workspace { /// Record the daemon's id for an open repository. pub fn set_repo_id(&mut self, repo: &str, id: &str) { - if let Some(project) = self.projects.iter_mut().find(|p| p.repo_path == repo) { - project.repo_id = Some(id.to_string()); + if let Some(project) = self + .projects + .iter_mut() + .find(|project| project.repository_path() == repo) + { + project.adopt_repository_id(id.to_string()); } } @@ -272,8 +279,7 @@ impl Workspace { // release will route to the newly active project. Deliver it to the // pane that saw the press instead of dropping the record — that PTY // is still alive, and with no release it would sit in a drag or - // selection state, while a leftover record could pair with an - // unrelated release later. + // selection state. self.projects[self.active].release_pending_press_in_place(); self.active = index; self.acknowledge_active_attention(); @@ -283,7 +289,9 @@ impl Workspace { /// onto the same repo — two tabs sharing a workdir would show identical /// git state while racing each other's snapshot workers. pub fn index_of_repo(&self, repo_path: &str) -> Option { - self.projects.iter().position(|p| p.repo_path == repo_path) + self.projects + .iter() + .position(|p| p.repository_path() == repo_path) } } diff --git a/src/workspace/path_complete.rs b/src/workspace/path_complete.rs index 7b447ea0..cf5929fa 100644 --- a/src/workspace/path_complete.rs +++ b/src/workspace/path_complete.rs @@ -1,9 +1,7 @@ -//! Tab completion for the repo dialog's path field. -//! -//! One `read_dir` per Tab press, against the single directory the buffer names. -//! Directories only: the dialog opens a repo and a file can never be one. -//! The dialog is not a shell, so only what `confirm_repo_input` itself accepts -//! is understood here: `~`, `..`, and cwd-relative paths. No `$VAR`, no globs. +//! Tab completion for the repo dialog's path field: one `read_dir` per Tab +//! press, directories only. The dialog is not a shell, so only what +//! `confirm_repo_input` itself accepts is understood here — `~`, `..`, and +//! cwd-relative paths. No `$VAR`, no globs. use std::path::{MAIN_SEPARATOR, Path}; @@ -96,7 +94,7 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { .filter(|n| n.to_lowercase().starts_with(&lower)) .collect(); } - // `read_dir_names` already sorted, and filtering preserves order. + // `read_dir_names` is sorted and filtering preserves order. match matches.len() { 0 => unchanged(), @@ -107,13 +105,13 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { }, _ => { let common = longest_common_prefix(&matches); - // Extending also corrects casing, so this fires whenever the shared - // prefix reads differently from what was typed, not only when it is - // longer. + // Extending also corrects casing, so this fires whenever the + // shared prefix reads differently from what was typed, not only + // when it is longer. let extended = common != frag; - // Listing and extending are independent. While typing can still be - // narrowed by an extension the list would be noise — except on a - // directory boundary, where an empty fragment means "what is in + // Listing and extending are independent. While typing could still + // be narrowed by an extension the list would be noise — except on + // a directory boundary, where an empty fragment means "what is in // here?" and a silent extension answers nothing. let candidates = if extended && !frag.is_empty() { Vec::new() @@ -133,9 +131,9 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { } /// `file_type` comes free with the directory read on most platforms; only a -/// symlink costs the extra stat to see what it points at. Symlinked checkouts -/// are common enough that reporting them as non-directories would hide real -/// repos, so unlike the in-repo tree navigator this one follows them. +/// symlink costs an extra stat. Symlinked checkouts are common enough that +/// reporting them as non-directories would hide real repos, so this follows +/// them (unlike the in-repo tree navigator). fn is_dir_entry(entry: &std::fs::DirEntry) -> bool { match entry.file_type() { Ok(t) if t.is_symlink() => entry.path().is_dir(), diff --git a/src/workspace/path_tree.rs b/src/workspace/path_tree.rs index 4ce3fa7d..74c33e40 100644 --- a/src/workspace/path_tree.rs +++ b/src/workspace/path_tree.rs @@ -3,10 +3,9 @@ //! A flat row list, not a nested tree: expanding splices a directory's children //! in after it and collapsing removes the rows below it. The root moves: `←` on //! a collapsed depth-0 row re-roots to the parent. Directories only, and nothing -//! here writes — the browser fills the field, and the field's own Enter stays the -//! single place a repo is actually opened. It deliberately does not reuse -//! `git::tree`, which requires a `git2::Repository` and refuses paths outside a -//! worktree. +//! here writes — the field's own Enter stays the single place a repo is opened. +//! It deliberately does not reuse `git::tree`, which requires a +//! `git2::Repository` and refuses paths outside a worktree. use super::path_complete::{is_sep, read_dir_names, split_dir}; use crate::platform::paths::expand_tilde; @@ -193,8 +192,7 @@ impl PathTree { // Verify the user-notation parent against the real one instead of // trusting the text surgery: `~` has no expressible parent, and neither // does a bare Windows drive. Falling back to the absolute path is the - // one place the dialog rewrites the user's text, because their notation - // cannot name where they just asked to go. + // one place the dialog rewrites the user's text. self.root_text = parent_text(&self.root_text, self.sep) .filter(|t| canonicalizes_to(t, &parent)) .unwrap_or_else(|| parent.to_string_lossy().to_string()); @@ -247,7 +245,7 @@ fn list_rows(dir: &Path, depth: usize) -> Vec { fn parent_text(text: &str, sep: char) -> Option { let t = text.trim_end_matches(is_sep); if t.is_empty() { - // `""` is the cwd, whose parent is `..`. All-separators is the + // `""` is the cwd, whose parent is `..`; all-separators is the // filesystem root, which has no parent for `re_root` to reach. return text.is_empty().then(|| "..".to_string()); } diff --git a/src/workspace/persistence.rs b/src/workspace/persistence.rs index 26f7e24c..42c19035 100644 --- a/src/workspace/persistence.rs +++ b/src/workspace/persistence.rs @@ -18,11 +18,9 @@ pub struct SessionState { pub mode: Option, #[serde(default)] pub log_selected: usize, - // No accent here: it is the session's, not one repository's view state, and - // lives in `viewer.json` (see the boundary in `docs/architecture.md`). An - // `accent_idx` left over from before is ignored on read rather than - // migrated — one of several per-repo colours cannot answer what the - // session's colour is. + // No accent here: it belongs to the session, not one repository's view + // state, and lives in `viewer.json` (see `docs/architecture.md`). A stale + // `accent_idx` is ignored on read rather than migrated. #[serde(default)] pub log_drill_down: bool, #[serde(default)] @@ -47,13 +45,12 @@ pub struct RepoSession { /// bound as repos are opened over the years. pub const MAX_REMEMBERED: usize = 50; -/// Everything nightcrow remembers between runs: which repositories were open, -/// which tab was in front, and each repository's view state. +/// Everything nightcrow remembers between runs: open repositories, the active +/// tab, and each repository's view state. /// -/// One file, under the config directory rather than inside any repository. -/// No single repo owns the fact that three others were open beside it, and -/// keeping view state out of the repos means nightcrow never creates a -/// directory in a project it is only reading. +/// One file, under the config directory: no single repo owns the tab list, and +/// keeping state out of the repos means nightcrow never writes into a project +/// it is only reading. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WorkspaceState { /// Absolute repo paths in tab order. @@ -105,9 +102,8 @@ fn load_workspace_at(path: &Path) -> Option { /// Record the open tabs. Called on exit, like the per-repo sessions, so a /// crash loses the tab list the same way it loses the rest of the session. /// -/// An empty list is written rather than skipped: closing every tab and -/// quitting is how a user asks for an empty screen next launch, and dropping -/// the write would resurrect the previous tabs instead. +/// An empty list is written rather than skipped: dropping the write would +/// resurrect the previous tabs instead of honoring "no tabs next launch". pub fn save_workspace(state: &WorkspaceState) { let Some(path) = workspace_path() else { return; diff --git a/src/workspace/repo_input.rs b/src/workspace/repo_input.rs index 990217b2..c7498b43 100644 --- a/src/workspace/repo_input.rs +++ b/src/workspace/repo_input.rs @@ -1,7 +1,7 @@ use super::Workspace; use crate::app::NoticeKind; -/// Outcome of confirming the dialog. The caller owns the workspace, so it does +/// Outcome of confirming the dialog. The caller owns the workspace and does /// the opening; this only hands back an accepted path. #[derive(Debug, PartialEq, Eq)] pub enum RepoInputResult { @@ -20,7 +20,7 @@ impl Workspace { pub fn start_repo_input(&mut self) { self.repo_input.buf = self .active() - .map(|p| p.repo_path.clone()) + .map(|p| p.repository_path().to_string()) .unwrap_or_default(); self.repo_input.active = true; self.repo_input.candidates.clear(); @@ -81,9 +81,10 @@ impl Workspace { self.repo_input.candidates = completed.candidates; } - /// Typing always extends the path, never replaces it. The prefill exists to - /// supply a shared prefix; wiping it on the first keystroke would throw that - /// away with nothing to undo it. Esc and Backspace discard. + /// Typing always extends the path, never replaces it — the prefill is + /// there to supply a shared prefix, and wiping it on the first keystroke + /// would throw that away with nothing to undo it. Esc and Backspace + /// discard. pub fn repo_input_push(&mut self, ch: char) { if self.repo_input.buf.len() + ch.len_utf8() > REPO_INPUT_MAX_BYTES { return; diff --git a/src/workspace/repo_picker.rs b/src/workspace/repo_picker.rs index a3745e9b..f11502f4 100644 --- a/src/workspace/repo_picker.rs +++ b/src/workspace/repo_picker.rs @@ -28,7 +28,7 @@ impl Workspace { } /// Take the selection into the field. Enter means the same thing on every - /// row — going anywhere the tree does not show is `←`'s job, not a row's. + /// row — 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; diff --git a/src/workspace/tests/common.rs b/src/workspace/tests/common.rs index fb438b5e..7de3bfeb 100644 --- a/src/workspace/tests/common.rs +++ b/src/workspace/tests/common.rs @@ -20,7 +20,7 @@ pub(super) fn workspace_on(paths: &[&str]) -> Workspace { /// what the tab row labels and `index_of_repo` match on. pub(super) fn project_at(path: &str) -> App { let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = path.to_string(); + app.git.repo_path = path.to_string(); app } @@ -31,5 +31,8 @@ pub(super) fn workspace_from(project: App) -> Workspace { } pub(super) fn paths(ws: &Workspace) -> Vec<&str> { - ws.projects().iter().map(|p| p.repo_path.as_str()).collect() + ws.projects() + .iter() + .map(|p| p.git.repo_path.as_str()) + .collect() } diff --git a/src/workspace/tests/daemon_set_tests.rs b/src/workspace/tests/daemon_set_tests.rs index ec0134b6..c84d1de1 100644 --- a/src/workspace/tests/daemon_set_tests.rs +++ b/src/workspace/tests/daemon_set_tests.rs @@ -7,7 +7,10 @@ use super::common::*; fn paths(ws: &crate::workspace::Workspace) -> Vec { - ws.projects().iter().map(|p| p.repo_path.clone()).collect() + ws.projects() + .iter() + .map(|p| p.git.repo_path.clone()) + .collect() } #[test] @@ -21,7 +24,7 @@ fn closing_a_repo_that_is_not_active_leaves_the_active_one_alone() { assert_eq!(paths(&ws), vec!["/b", "/c"]); assert_eq!( - ws.active().unwrap().repo_path, + ws.active().unwrap().git.repo_path, "/c", "the active project must not shift onto its neighbour" ); @@ -35,7 +38,7 @@ fn closing_the_active_repo_falls_back_to_a_neighbour() { assert!(ws.close_repo("/b")); assert_eq!(paths(&ws), vec!["/a", "/c"]); - assert_eq!(ws.active().unwrap().repo_path, "/c"); + assert_eq!(ws.active().unwrap().git.repo_path, "/c"); } #[test] @@ -90,7 +93,7 @@ fn reordering_keeps_the_same_project_active() { ws.reorder_to(&["/c", "/b", "/a"]); - assert_eq!(ws.active().unwrap().repo_path, "/a"); + assert_eq!(ws.active().unwrap().git.repo_path, "/a"); assert_eq!(ws.active_index(), 2); } @@ -132,8 +135,8 @@ fn recording_an_id_names_the_repository_it_was_given_for() { ws.set_repo_id("/b", "r7"); - assert_eq!(ws.projects()[1].repo_id.as_deref(), Some("r7")); - assert_eq!(ws.projects()[0].repo_id, None); + assert_eq!(ws.projects()[1].git.repo_id.as_deref(), Some("r7")); + assert_eq!(ws.projects()[0].git.repo_id, None); } #[test] @@ -142,5 +145,5 @@ fn recording_an_id_for_a_repo_that_is_not_open_changes_nothing() { ws.set_repo_id("/gone", "r7"); - assert_eq!(ws.projects()[0].repo_id, None); + assert_eq!(ws.projects()[0].git.repo_id, None); } diff --git a/src/workspace/tests/repo_input_tests.rs b/src/workspace/tests/repo_input_tests.rs index 440b935e..fb6ad3c3 100644 --- a/src/workspace/tests/repo_input_tests.rs +++ b/src/workspace/tests/repo_input_tests.rs @@ -182,5 +182,5 @@ fn 새_workspace는_프로젝트_하나를_활성으로_갖는다() { let ws = workspace_from(app_with_files(vec!["a.rs"])); assert_eq!(ws.projects().len(), 1); - assert_eq!(ws.active().unwrap().repo_path, "."); + assert_eq!(ws.active().unwrap().git.repo_path, "."); } diff --git a/src/workspace/tests/workspace_tests.rs b/src/workspace/tests/workspace_tests.rs index fcab52a8..9bd48583 100644 --- a/src/workspace/tests/workspace_tests.rs +++ b/src/workspace/tests/workspace_tests.rs @@ -48,7 +48,7 @@ fn 프로젝트를_추가하면_끝에_붙고_활성이_된다() { assert!(ws.add(project_at("/b"))); assert_eq!(paths(&ws), vec!["/a", "/b"]); - assert_eq!(ws.active().unwrap().repo_path, "/b"); + assert_eq!(ws.active().unwrap().git.repo_path, "/b"); } #[test] @@ -58,12 +58,12 @@ fn 상한에_도달하면_추가를_거부하고_활성을_유지한다() { assert!(ws.add(project_at(&format!("/p{i}")))); } assert_eq!(ws.projects().len(), MAX_PROJECTS); - let active_before = ws.active().unwrap().repo_path.clone(); + let active_before = ws.active().unwrap().git.repo_path.clone(); assert!(!ws.add(project_at("/overflow"))); assert_eq!(ws.projects().len(), MAX_PROJECTS); - assert_eq!(ws.active().unwrap().repo_path, active_before); + assert_eq!(ws.active().unwrap().git.repo_path, active_before); assert!(ws.index_of_repo("/overflow").is_none()); } @@ -77,7 +77,7 @@ fn 가운데_탭을_닫으면_뒤_탭이_활성이_된다() { assert!(ws.close_repo("/b")); assert_eq!(paths(&ws), vec!["/a", "/c"]); - assert_eq!(ws.active().unwrap().repo_path, "/c"); + assert_eq!(ws.active().unwrap().git.repo_path, "/c"); } #[test] @@ -88,7 +88,7 @@ fn 마지막_탭을_닫으면_앞_탭이_활성이_된다() { assert!(ws.close_repo("/b")); assert_eq!(paths(&ws), vec!["/a"]); - assert_eq!(ws.active().unwrap().repo_path, "/a"); + assert_eq!(ws.active().unwrap().git.repo_path, "/a"); } #[test] @@ -138,7 +138,7 @@ fn 범위를_벗어난_전환은_활성을_바꾸지_않는다() { ws.switch(9); - assert_eq!(ws.active().unwrap().repo_path, "/b"); + assert_eq!(ws.active().unwrap().git.repo_path, "/b"); } #[test] diff --git a/viewer-ui/dist/assets/Html-B6C-L9Iq.js b/viewer-ui/dist/assets/Html-ip9DjFbW.js similarity index 70% rename from viewer-ui/dist/assets/Html-B6C-L9Iq.js rename to viewer-ui/dist/assets/Html-ip9DjFbW.js index e26b30f9..e3415898 100644 --- a/viewer-ui/dist/assets/Html-B6C-L9Iq.js +++ b/viewer-ui/dist/assets/Html-ip9DjFbW.js @@ -1 +1 @@ -import{u as e}from"./index-DUhsGIkz.js";var t=e();function n({src:e}){return(0,t.jsx)(`iframe`,{title:`HTML preview`,sandbox:`allow-scripts`,src:e,className:`h-full w-full border-0 bg-white`})}export{n as HtmlView}; \ No newline at end of file +import{u as e}from"./index-DoNXZFdA.js";var t=e();function n({src:e}){return(0,t.jsx)(`iframe`,{title:`HTML preview`,sandbox:`allow-scripts`,src:e,className:`h-full w-full border-0 bg-white`})}export{n as HtmlView}; \ No newline at end of file diff --git a/viewer-ui/dist/assets/Markdown-Do6z-tD8.js b/viewer-ui/dist/assets/Markdown-CDxnZumR.js similarity index 99% rename from viewer-ui/dist/assets/Markdown-Do6z-tD8.js rename to viewer-ui/dist/assets/Markdown-CDxnZumR.js index e7853fe9..323aa839 100644 --- a/viewer-ui/dist/assets/Markdown-Do6z-tD8.js +++ b/viewer-ui/dist/assets/Markdown-CDxnZumR.js @@ -1,4 +1,4 @@ -import{g as e,h as t,m as n,p as r,u as i}from"./index-DUhsGIkz.js";function a(e,t){let n=t||{};return(e[e.length-1]===``?[...e,``]:e).join((n.padRight?` `:``)+`,`+(n.padLeft===!1?``:` `)).trim()}var o=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,s=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,c={};function l(e,t){return((t||c).jsx?s:o).test(e)}var u=/[ \t\n\f\r]/g;function d(e){return typeof e==`object`?e.type===`text`&&f(e.value):f(e)}function f(e){return e.replace(u,``)===``}var p=class{constructor(e,t,n){this.normal=t,this.property=e,n&&(this.space=n)}};p.prototype.normal={},p.prototype.property={},p.prototype.space=void 0;function m(e,t){let n={},r={};for(let t of e)Object.assign(n,t.property),Object.assign(r,t.normal);return new p(n,r,t)}function h(e){return e.toLowerCase()}var g=class{constructor(e,t){this.attribute=t,this.property=e}};g.prototype.attribute=``,g.prototype.booleanish=!1,g.prototype.boolean=!1,g.prototype.commaOrSpaceSeparated=!1,g.prototype.commaSeparated=!1,g.prototype.defined=!1,g.prototype.mustUseProperty=!1,g.prototype.number=!1,g.prototype.overloadedBoolean=!1,g.prototype.property=``,g.prototype.spaceSeparated=!1,g.prototype.space=void 0;var _=t({boolean:()=>y,booleanish:()=>b,commaOrSpaceSeparated:()=>T,commaSeparated:()=>w,number:()=>S,overloadedBoolean:()=>x,spaceSeparated:()=>C}),v=0,y=E(),b=E(),x=E(),S=E(),C=E(),w=E(),T=E();function E(){return 2**++v}var D=Object.keys(_),O=class extends g{constructor(e,t,n,r){let i=-1;if(super(e,t),k(this,`space`,r),typeof n==`number`)for(;++i4&&n.slice(0,4)===`data`&&re.test(t)){if(t.charAt(4)===`-`){let e=t.slice(5).replace(ne,oe);r=`data`+e.charAt(0).toUpperCase()+e.slice(1)}else{let e=t.slice(4);if(!ne.test(e)){let n=e.replace(te,ae);n.charAt(0)!==`-`&&(n=`-`+n),t=`data`+n}}i=O}return new i(r,t)}function ae(e){return`-`+e.toLowerCase()}function oe(e){return e.charAt(1).toUpperCase()}var se=m([j,P,I,L,R],`html`),ce=m([j,F,I,L,R],`svg`);function le(e){return e.join(` `).trim()}var ue=n(((e,t)=>{var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,i=/^\s*/,a=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,o=/^:\s*/,s=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,c=/^[;\s]*/,l=/^\s+|\s+$/g;function u(e,t){if(typeof e!=`string`)throw TypeError(`First argument must be a string`);if(!e)return[];t||={};var l=1,u=1;function f(e){var t=e.match(r);t&&(l+=t.length);var n=e.lastIndexOf(` +import{g as e,h as t,m as n,p as r,u as i}from"./index-DoNXZFdA.js";function a(e,t){let n=t||{};return(e[e.length-1]===``?[...e,``]:e).join((n.padRight?` `:``)+`,`+(n.padLeft===!1?``:` `)).trim()}var o=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,s=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,c={};function l(e,t){return((t||c).jsx?s:o).test(e)}var u=/[ \t\n\f\r]/g;function d(e){return typeof e==`object`?e.type===`text`&&f(e.value):f(e)}function f(e){return e.replace(u,``)===``}var p=class{constructor(e,t,n){this.normal=t,this.property=e,n&&(this.space=n)}};p.prototype.normal={},p.prototype.property={},p.prototype.space=void 0;function m(e,t){let n={},r={};for(let t of e)Object.assign(n,t.property),Object.assign(r,t.normal);return new p(n,r,t)}function h(e){return e.toLowerCase()}var g=class{constructor(e,t){this.attribute=t,this.property=e}};g.prototype.attribute=``,g.prototype.booleanish=!1,g.prototype.boolean=!1,g.prototype.commaOrSpaceSeparated=!1,g.prototype.commaSeparated=!1,g.prototype.defined=!1,g.prototype.mustUseProperty=!1,g.prototype.number=!1,g.prototype.overloadedBoolean=!1,g.prototype.property=``,g.prototype.spaceSeparated=!1,g.prototype.space=void 0;var _=t({boolean:()=>y,booleanish:()=>b,commaOrSpaceSeparated:()=>T,commaSeparated:()=>w,number:()=>S,overloadedBoolean:()=>x,spaceSeparated:()=>C}),v=0,y=E(),b=E(),x=E(),S=E(),C=E(),w=E(),T=E();function E(){return 2**++v}var D=Object.keys(_),O=class extends g{constructor(e,t,n,r){let i=-1;if(super(e,t),k(this,`space`,r),typeof n==`number`)for(;++i4&&n.slice(0,4)===`data`&&re.test(t)){if(t.charAt(4)===`-`){let e=t.slice(5).replace(ne,oe);r=`data`+e.charAt(0).toUpperCase()+e.slice(1)}else{let e=t.slice(4);if(!ne.test(e)){let n=e.replace(te,ae);n.charAt(0)!==`-`&&(n=`-`+n),t=`data`+n}}i=O}return new i(r,t)}function ae(e){return`-`+e.toLowerCase()}function oe(e){return e.charAt(1).toUpperCase()}var se=m([j,P,I,L,R],`html`),ce=m([j,F,I,L,R],`svg`);function le(e){return e.join(` `).trim()}var ue=n(((e,t)=>{var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,i=/^\s*/,a=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,o=/^:\s*/,s=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,c=/^[;\s]*/,l=/^\s+|\s+$/g;function u(e,t){if(typeof e!=`string`)throw TypeError(`First argument must be a string`);if(!e)return[];t||={};var l=1,u=1;function f(e){var t=e.match(r);t&&(l+=t.length);var n=e.lastIndexOf(` `);u=~n?e.length-n:u+e.length}function p(){var e={line:l,column:u};return function(t){return t.position=new m(e),_(),t}}function m(e){this.start=e,this.end={line:l,column:u},this.source=t.source}m.prototype.content=e;function h(n){var r=Error(t.source+`:`+l+`:`+u+`: `+n);if(r.reason=n,r.filename=t.source,r.line=l,r.column=u,r.source=e,!t.silent)throw r}function g(t){var n=t.exec(e);if(n){var r=n[0];return f(r),e=e.slice(r.length),n}}function _(){g(i)}function v(e){var t;for(e||=[];t=y();)t!==!1&&e.push(t);return e}function y(){var t=p();if(!(e.charAt(0)!=`/`||e.charAt(1)!=`*`)){for(var n=2;e.charAt(n)!=``&&(e.charAt(n)!=`*`||e.charAt(n+1)!=`/`);)++n;if(n+=2,e.charAt(n-1)===``)return h(`End of comment missing`);var r=e.slice(2,n-2);return u+=2,f(r),e=e.slice(n),u+=2,t({type:`comment`,comment:r})}}function b(){var e=p(),t=g(a);if(t){if(y(),!g(o))return h(`property missing ':'`);var r=g(s),i=e({type:`declaration`,property:d(t[0].replace(n,``)),value:r?d(r[0].replace(n,``)):``});return g(c),i}}function x(){var e=[];v(e);for(var t;t=b();)t!==!1&&(e.push(t),v(e));return e}return _(),x()}function d(e){return e?e.replace(l,``):``}t.exports=u})),de=n((e=>{var t=e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(e,"__esModule",{value:!0}),e.default=r;var n=t(ue());function r(e,t){let r=null;if(!e||typeof e!=`string`)return r;let i=(0,n.default)(e),a=typeof t==`function`;return i.forEach(e=>{if(e.type!==`declaration`)return;let{property:n,value:i}=e;a?t(n,i,e):i&&(r||={},r[n]=i)}),r}})),fe=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.camelCase=void 0;var t=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,r=/^[^-]+$/,i=/^-(webkit|moz|ms|o|khtml)-/,a=/^-(ms)-/,o=function(e){return!e||r.test(e)||t.test(e)},s=function(e,t){return t.toUpperCase()},c=function(e,t){return`${t}-`};e.camelCase=function(e,t){return t===void 0&&(t={}),o(e)?e:(e=e.toLowerCase(),e=t.reactCompat?e.replace(a,c):e.replace(i,c),e.replace(n,s))}})),pe=n(((e,t)=>{var n=(e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}})(de()),r=fe();function i(e,t){var i={};return!e||typeof e!=`string`||(0,n.default)(e,function(e,n){e&&n&&(i[(0,r.camelCase)(e,t)]=n)}),i}i.default=i,t.exports=i})),me=he(`end`),z=he(`start`);function he(e){return t;function t(t){let n=t&&t.position&&t.position[e]||{};if(typeof n.line==`number`&&n.line>0&&typeof n.column==`number`&&n.column>0)return{line:n.line,column:n.column,offset:typeof n.offset==`number`&&n.offset>-1?n.offset:void 0}}}function ge(e){let t=z(e),n=me(e);if(t&&n)return{start:t,end:n}}function _e(e){return!e||typeof e!=`object`?``:`position`in e||`type`in e?ye(e.position):`start`in e||`end`in e?ye(e):`line`in e||`column`in e?ve(e):``}function ve(e){return be(e&&e.line)+`:`+be(e&&e.column)}function ye(e){return ve(e&&e.start)+`-`+ve(e&&e.end)}function be(e){return e&&typeof e==`number`?e:1}var B=class extends Error{constructor(e,t,n){super(),typeof t==`string`&&(n=t,t=void 0);let r=``,i={},a=!1;if(t&&(i=`line`in t&&`column`in t||`start`in t&&`end`in t?{place:t}:`type`in t?{ancestors:[t],place:t.position}:{...t}),typeof e==`string`?r=e:!i.cause&&e&&(a=!0,r=e.message,i.cause=e),!i.ruleId&&!i.source&&typeof n==`string`){let e=n.indexOf(`:`);e===-1?i.ruleId=n:(i.source=n.slice(0,e),i.ruleId=n.slice(e+1))}if(!i.place&&i.ancestors&&i.ancestors){let e=i.ancestors[i.ancestors.length-1];e&&(i.place=e.position)}let o=i.place&&`start`in i.place?i.place.start:i.place;this.ancestors=i.ancestors||void 0,this.cause=i.cause||void 0,this.column=o?o.column:void 0,this.fatal=void 0,this.file=``,this.message=r,this.line=o?o.line:void 0,this.name=_e(i.place)||`1:1`,this.place=i.place||void 0,this.reason=this.message,this.ruleId=i.ruleId||void 0,this.source=i.source||void 0,this.stack=a&&i.cause&&typeof i.cause.stack==`string`?i.cause.stack:``,this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}};B.prototype.file=``,B.prototype.name=``,B.prototype.reason=``,B.prototype.message=``,B.prototype.stack=``,B.prototype.column=void 0,B.prototype.line=void 0,B.prototype.ancestors=void 0,B.prototype.cause=void 0,B.prototype.fatal=void 0,B.prototype.place=void 0,B.prototype.ruleId=void 0,B.prototype.source=void 0;var xe=e(pe(),1),Se={}.hasOwnProperty,Ce=new Map,we=/[A-Z]/g,Te=new Set([`table`,`tbody`,`thead`,`tfoot`,`tr`]),Ee=new Set([`td`,`th`]);function De(e,t){if(!t||t.Fragment===void 0)throw TypeError("Expected `Fragment` in options");let n=t.filePath||void 0,r;if(t.development){if(typeof t.jsxDEV!=`function`)throw TypeError("Expected `jsxDEV` in options when `development: true`");r=Re(n,t.jsxDEV)}else{if(typeof t.jsx!=`function`)throw TypeError("Expected `jsx` in production options");if(typeof t.jsxs!=`function`)throw TypeError("Expected `jsxs` in production options");r=Le(n,t.jsx,t.jsxs)}let i={Fragment:t.Fragment,ancestors:[],components:t.components||{},create:r,elementAttributeNameCase:t.elementAttributeNameCase||`react`,evaluater:t.createEvaluater?t.createEvaluater():void 0,filePath:n,ignoreInvalidStyle:t.ignoreInvalidStyle||!1,passKeys:t.passKeys!==!1,passNode:t.passNode||!1,schema:t.space===`svg`?ce:se,stylePropertyNameCase:t.stylePropertyNameCase||`dom`,tableCellAlignToStyle:t.tableCellAlignToStyle!==!1},a=Oe(i,e,void 0);return a&&typeof a!=`string`?a:i.create(e,i.Fragment,{children:a||void 0},void 0)}function Oe(e,t,n){if(t.type===`element`)return ke(e,t,n);if(t.type===`mdxFlowExpression`||t.type===`mdxTextExpression`)return Ae(e,t);if(t.type===`mdxJsxFlowElement`||t.type===`mdxJsxTextElement`)return Me(e,t,n);if(t.type===`mdxjsEsm`)return je(e,t);if(t.type===`root`)return Ne(e,t,n);if(t.type===`text`)return Pe(e,t)}function ke(e,t,n){let r=e.schema,i=r;t.tagName.toLowerCase()===`svg`&&r.space===`html`&&(i=ce,e.schema=i),e.ancestors.push(t);let a=We(e,t.tagName,!1),o=ze(e,t),s=Ve(e,t);return Te.has(t.tagName)&&(s=s.filter(function(e){return typeof e!=`string`||!d(e)})),Fe(e,o,a,t),Ie(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Ae(e,t){if(t.data&&t.data.estree&&e.evaluater){let n=t.data.estree.body[0];return n.type,e.evaluater.evaluateExpression(n.expression)}Ge(e,t.position)}function je(e,t){if(t.data&&t.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(t.data.estree);Ge(e,t.position)}function Me(e,t,n){let r=e.schema,i=r;t.name===`svg`&&r.space===`html`&&(i=ce,e.schema=i),e.ancestors.push(t);let a=t.name===null?e.Fragment:We(e,t.name,!0),o=Be(e,t),s=Ve(e,t);return Fe(e,o,a,t),Ie(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Ne(e,t,n){let r={};return Ie(r,Ve(e,t)),e.create(t,e.Fragment,r,n)}function Pe(e,t){return t.value}function Fe(e,t,n,r){typeof n!=`string`&&n!==e.Fragment&&e.passNode&&(t.node=r)}function Ie(e,t){if(t.length>0){let n=t.length>1?t:t[0];n&&(e.children=n)}}function Le(e,t,n){return r;function r(e,r,i,a){let o=Array.isArray(i.children)?n:t;return a?o(r,i,a):o(r,i)}}function Re(e,t){return n;function n(n,r,i,a){let o=Array.isArray(i.children),s=z(n);return t(r,i,a,o,{columnNumber:s?s.column-1:void 0,fileName:e,lineNumber:s?s.line:void 0},void 0)}}function ze(e,t){let n={},r,i;for(i in t.properties)if(i!==`children`&&Se.call(t.properties,i)){let a=He(e,i,t.properties[i]);if(a){let[i,o]=a;e.tableCellAlignToStyle&&i===`align`&&typeof o==`string`&&Ee.has(t.tagName)?r=o:n[i]=o}}if(r){let t=n.style||={};t[e.stylePropertyNameCase===`css`?`text-align`:`textAlign`]=r}return n}function Be(e,t){let n={};for(let r of t.attributes)if(r.type===`mdxJsxExpressionAttribute`)if(r.data&&r.data.estree&&e.evaluater){let t=r.data.estree.body[0];t.type;let i=t.expression;i.type;let a=i.properties[0];a.type,Object.assign(n,e.evaluater.evaluateExpression(a.argument))}else Ge(e,t.position);else{let i=r.name,a;if(r.value&&typeof r.value==`object`)if(r.value.data&&r.value.data.estree&&e.evaluater){let t=r.value.data.estree.body[0];t.type,a=e.evaluater.evaluateExpression(t.expression)}else Ge(e,t.position);else a=r.value===null||r.value;n[i]=a}return n}function Ve(e,t){let n=[],r=-1,i=e.passKeys?new Map:Ce;for(;++ri?0:i+t:t>i?i:t,n=n>0?n:0,r.length<1e4)o=Array.from(r),o.unshift(t,n),e.splice(...o);else for(n&&e.splice(t,n);a0?(V(e,e.length,0,t),e):t}var rt={}.hasOwnProperty;function it(e){let t={},n=-1;for(;++n13&&n<32||n>126&&n<160||n>55295&&n<57344||n>64975&&n<65008||(n&65535)==65535||(n&65535)==65534||n>1114111?`�`:String.fromCodePoint(n)}function U(e){return e.replace(/[\t\n\r ]+/g,` `).replace(/^ | $/g,``).toLowerCase().toUpperCase()}var W=ht(/[A-Za-z]/),G=ht(/[\dA-Za-z]/),ct=ht(/[#-'*+\--9=?A-Z^-~]/);function lt(e){return e!==null&&(e<32||e===127)}var ut=ht(/\d/),dt=ht(/[\dA-Fa-f]/),ft=ht(/[!-/:-@[-`{-~]/);function K(e){return e!==null&&e<-2}function q(e){return e!==null&&(e<0||e===32)}function J(e){return e===-2||e===-1||e===32}var pt=ht(/\p{P}|\p{S}/u),mt=ht(/\s/);function ht(e){return t;function t(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function gt(e){let t=[],n=-1,r=0,i=0;for(;++n55295&&a<57344){let t=e.charCodeAt(n+1);a<56320&&t>56319&&t<57344?(o=String.fromCharCode(a,t),i=1):o=`�`}else o=String.fromCharCode(a);o&&=(t.push(e.slice(r,n),encodeURIComponent(o)),r=n+i+1,``),i&&=(n+=i,0)}return t.join(``)+e.slice(r)}function Y(e,t,n,r){let i=r?r-1:1/0,a=0;return o;function o(r){return J(r)?(e.enter(n),s(r)):t(r)}function s(r){return J(r)&&a++o))return;let n=t.events.length,a=n,s,c;for(;a--;)if(t.events[a][0]===`exit`&&t.events[a][1].type===`chunkFlow`){if(s){c=t.events[a][1].end;break}s=!0}for(_(r),e=n;er;){let r=n[i];t.containerState=r[1],r[0].exit.call(t,e)}n.length=r}function v(){i.write([null]),a=void 0,i=void 0,t.containerState._closeFlow=void 0}}function St(e,t,n){return Y(e,e.attempt(this.parser.constructs.document,t,n),`linePrefix`,this.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)}function Ct(e){if(e===null||q(e)||mt(e))return 1;if(pt(e))return 2}function wt(e,t,n){let r=[],i=-1;for(;++i1&&e[n][1].end.offset-e[n][1].start.offset>1?2:1;let d={...e[r][1].end},f={...e[n][1].start};Ot(d,-c),Ot(f,c),o={type:c>1?`strongSequence`:`emphasisSequence`,start:d,end:{...e[r][1].end}},s={type:c>1?`strongSequence`:`emphasisSequence`,start:{...e[n][1].start},end:f},a={type:c>1?`strongText`:`emphasisText`,start:{...e[r][1].end},end:{...e[n][1].start}},i={type:c>1?`strong`:`emphasis`,start:{...o.start},end:{...s.end}},e[r][1].end={...o.start},e[n][1].start={...s.end},l=[],e[r][1].end.offset-e[r][1].start.offset&&(l=H(l,[[`enter`,e[r][1],t],[`exit`,e[r][1],t]])),l=H(l,[[`enter`,i,t],[`enter`,o,t],[`exit`,o,t],[`enter`,a,t]]),l=H(l,wt(t.parser.constructs.insideSpan.null,e.slice(r+1,n),t)),l=H(l,[[`exit`,a,t],[`enter`,s,t],[`exit`,s,t],[`exit`,i,t]]),e[n][1].end.offset-e[n][1].start.offset?(u=2,l=H(l,[[`enter`,e[n][1],t],[`exit`,e[n][1],t]])):u=0,V(e,r-1,n-r+3,l),n=r+l.length-u-2;break}}for(n=-1;++n0&&J(t)?Y(e,v,`linePrefix`,a+1)(t):v(t)}function v(t){return t===null||K(t)?e.check(Vt,h,b)(t):(e.enter(`codeFlowValue`),y(t))}function y(t){return t===null||K(t)?(e.exit(`codeFlowValue`),v(t)):(e.consume(t),y)}function b(n){return e.exit(`codeFenced`),t(n)}function x(e,t,n){let i=0;return a;function a(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),c}function c(t){return e.enter(`codeFencedFence`),J(t)?Y(e,l,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):l(t)}function l(t){return t===s?(e.enter(`codeFencedFenceSequence`),u(t)):n(t)}function u(t){return t===s?(i++,e.consume(t),u):i>=o?(e.exit(`codeFencedFenceSequence`),J(t)?Y(e,d,`whitespace`)(t):d(t)):n(t)}function d(r){return r===null||K(r)?(e.exit(`codeFencedFence`),t(r)):n(r)}}}function Wt(e,t,n){let r=this;return i;function i(t){return t===null?n(t):(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}var Gt={name:`codeIndented`,tokenize:qt},Kt={partial:!0,tokenize:Jt};function qt(e,t,n){let r=this;return i;function i(t){return e.enter(`codeIndented`),Y(e,a,`linePrefix`,5)(t)}function a(e){let t=r.events[r.events.length-1];return t&&t[1].type===`linePrefix`&&t[2].sliceSerialize(t[1],!0).length>=4?o(e):n(e)}function o(t){return t===null?c(t):K(t)?e.attempt(Kt,o,c)(t):(e.enter(`codeFlowValue`),s(t))}function s(t){return t===null||K(t)?(e.exit(`codeFlowValue`),o(t)):(e.consume(t),s)}function c(n){return e.exit(`codeIndented`),t(n)}}function Jt(e,t,n){let r=this;return i;function i(t){return r.parser.lazy[r.now().line]?n(t):K(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),i):Y(e,a,`linePrefix`,5)(t)}function a(e){let a=r.events[r.events.length-1];return a&&a[1].type===`linePrefix`&&a[2].sliceSerialize(a[1],!0).length>=4?t(e):K(e)?i(e):n(e)}}var Yt={name:`codeText`,previous:Zt,resolve:Xt,tokenize:Qt};function Xt(e){let t=e.length-4,n=3,r,i;if((e[n][1].type===`lineEnding`||e[n][1].type===`space`)&&(e[t][1].type===`lineEnding`||e[t][1].type===`space`)){for(r=n;++r=this.left.length+this.right.length)throw RangeError("Cannot access index `"+e+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return ethis.left.length?this.right.slice(this.right.length-n+this.left.length,this.right.length-e+this.left.length).reverse():this.left.slice(e).concat(this.right.slice(this.right.length-n+this.left.length).reverse())}splice(e,t,n){let r=t||0;this.setCursor(Math.trunc(e));let i=this.right.splice(this.right.length-r,1/0);return n&&en(this.left,n),i.reverse()}pop(){return this.setCursor(1/0),this.left.pop()}push(e){this.setCursor(1/0),this.left.push(e)}pushMany(e){this.setCursor(1/0),en(this.left,e)}unshift(e){this.setCursor(0),this.right.push(e)}unshiftMany(e){this.setCursor(0),en(this.right,e.reverse())}setCursor(e){if(!(e===this.left.length||e>this.left.length&&this.right.length===0||e<0&&this.left.length===0))if(e=4?t(i):e.interrupt(r.parser.constructs.flow,n,t)(i)}}function ln(e,t,n,r,i,a,o,s,c){let l=c||1/0,u=0;return d;function d(t){return t===60?(e.enter(r),e.enter(i),e.enter(a),e.consume(t),e.exit(a),f):t===null||t===32||t===41||lt(t)?n(t):(e.enter(r),e.enter(o),e.enter(s),e.enter(`chunkString`,{contentType:`string`}),h(t))}function f(n){return n===62?(e.enter(a),e.consume(n),e.exit(a),e.exit(i),e.exit(r),t):(e.enter(s),e.enter(`chunkString`,{contentType:`string`}),p(n))}function p(t){return t===62?(e.exit(`chunkString`),e.exit(s),f(t)):t===null||t===60||K(t)?n(t):(e.consume(t),t===92?m:p)}function m(t){return t===60||t===62||t===92?(e.consume(t),p):p(t)}function h(i){return!u&&(i===null||i===41||q(i))?(e.exit(`chunkString`),e.exit(s),e.exit(o),e.exit(r),t(i)):u999||l===null||l===91||l===93&&!c||l===94&&!s&&`_hiddenFootnoteSupport`in o.parser.constructs?n(l):l===93?(e.exit(a),e.enter(i),e.consume(l),e.exit(i),e.exit(r),t):K(l)?(e.enter(`lineEnding`),e.consume(l),e.exit(`lineEnding`),u):(e.enter(`chunkString`,{contentType:`string`}),d(l))}function d(t){return t===null||t===91||t===93||K(t)||s++>999?(e.exit(`chunkString`),u(t)):(e.consume(t),c||=!J(t),t===92?f:d)}function f(t){return t===91||t===92||t===93?(e.consume(t),s++,d):d(t)}}function dn(e,t,n,r,i,a){let o;return s;function s(t){return t===34||t===39||t===40?(e.enter(r),e.enter(i),e.consume(t),e.exit(i),o=t===40?41:t,c):n(t)}function c(n){return n===o?(e.enter(i),e.consume(n),e.exit(i),e.exit(r),t):(e.enter(a),l(n))}function l(t){return t===o?(e.exit(a),c(o)):t===null?n(t):K(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),Y(e,l,`linePrefix`)):(e.enter(`chunkString`,{contentType:`string`}),u(t))}function u(t){return t===o||t===null||K(t)?(e.exit(`chunkString`),l(t)):(e.consume(t),t===92?d:u)}function d(t){return t===o||t===92?(e.consume(t),u):u(t)}}function fn(e,t){let n;return r;function r(i){return K(i)?(e.enter(`lineEnding`),e.consume(i),e.exit(`lineEnding`),n=!0,r):J(i)?Y(e,r,n?`linePrefix`:`lineSuffix`)(i):t(i)}}var pn={name:`definition`,tokenize:hn},mn={partial:!0,tokenize:gn};function hn(e,t,n){let r=this,i;return a;function a(t){return e.enter(`definition`),o(t)}function o(t){return un.call(r,e,s,n,`definitionLabel`,`definitionLabelMarker`,`definitionLabelString`)(t)}function s(t){return i=U(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),c):n(t)}function c(t){return q(t)?fn(e,l)(t):l(t)}function l(t){return ln(e,u,n,`definitionDestination`,`definitionDestinationLiteral`,`definitionDestinationLiteralMarker`,`definitionDestinationRaw`,`definitionDestinationString`)(t)}function u(t){return e.attempt(mn,d,d)(t)}function d(t){return J(t)?Y(e,f,`whitespace`)(t):f(t)}function f(a){return a===null||K(a)?(e.exit(`definition`),r.parser.defined.push(i),t(a)):n(a)}}function gn(e,t,n){return r;function r(t){return q(t)?fn(e,i)(t):n(t)}function i(t){return dn(e,a,n,`definitionTitle`,`definitionTitleMarker`,`definitionTitleString`)(t)}function a(t){return J(t)?Y(e,o,`whitespace`)(t):o(t)}function o(e){return e===null||K(e)?t(e):n(e)}}var _n={name:`hardBreakEscape`,tokenize:vn};function vn(e,t,n){return r;function r(t){return e.enter(`hardBreakEscape`),e.consume(t),i}function i(r){return K(r)?(e.exit(`hardBreakEscape`),t(r)):n(r)}}var yn={name:`headingAtx`,resolve:bn,tokenize:xn};function bn(e,t){let n=e.length-2,r=3,i,a;return e[r][1].type===`whitespace`&&(r+=2),n-2>r&&e[n][1].type===`whitespace`&&(n-=2),e[n][1].type===`atxHeadingSequence`&&(r===n-1||n-4>r&&e[n-2][1].type===`whitespace`)&&(n-=r+1===n?2:4),n>r&&(i={type:`atxHeadingText`,start:e[r][1].start,end:e[n][1].end},a={type:`chunkText`,start:e[r][1].start,end:e[n][1].end,contentType:`text`},V(e,r,n-r+1,[[`enter`,i,t],[`enter`,a,t],[`exit`,a,t],[`exit`,i,t]])),e}function xn(e,t,n){let r=0;return i;function i(t){return e.enter(`atxHeading`),a(t)}function a(t){return e.enter(`atxHeadingSequence`),o(t)}function o(t){return t===35&&r++<6?(e.consume(t),o):t===null||q(t)?(e.exit(`atxHeadingSequence`),s(t)):n(t)}function s(n){return n===35?(e.enter(`atxHeadingSequence`),c(n)):n===null||K(n)?(e.exit(`atxHeading`),t(n)):J(n)?Y(e,s,`whitespace`)(n):(e.enter(`atxHeadingText`),l(n))}function c(t){return t===35?(e.consume(t),c):(e.exit(`atxHeadingSequence`),s(t))}function l(t){return t===null||t===35||q(t)?(e.exit(`atxHeadingText`),s(t)):(e.consume(t),l)}}var Sn=`address.article.aside.base.basefont.blockquote.body.caption.center.col.colgroup.dd.details.dialog.dir.div.dl.dt.fieldset.figcaption.figure.footer.form.frame.frameset.h1.h2.h3.h4.h5.h6.head.header.hr.html.iframe.legend.li.link.main.menu.menuitem.nav.noframes.ol.optgroup.option.p.param.search.section.summary.table.tbody.td.tfoot.th.thead.title.tr.track.ul`.split(`.`),Cn=[`pre`,`script`,`style`,`textarea`],wn={concrete:!0,name:`htmlFlow`,resolveTo:Dn,tokenize:On},Tn={partial:!0,tokenize:An},En={partial:!0,tokenize:kn};function Dn(e){let t=e.length;for(;t--&&!(e[t][0]===`enter`&&e[t][1].type===`htmlFlow`););return t>1&&e[t-2][1].type===`linePrefix`&&(e[t][1].start=e[t-2][1].start,e[t+1][1].start=e[t-2][1].start,e.splice(t-2,2)),e}function On(e,t,n){let r=this,i,a,o,s,c;return l;function l(e){return u(e)}function u(t){return e.enter(`htmlFlow`),e.enter(`htmlFlowData`),e.consume(t),d}function d(s){return s===33?(e.consume(s),f):s===47?(e.consume(s),a=!0,h):s===63?(e.consume(s),i=3,r.interrupt?t:I):W(s)?(e.consume(s),o=String.fromCharCode(s),g):n(s)}function f(a){return a===45?(e.consume(a),i=2,p):a===91?(e.consume(a),i=5,s=0,m):W(a)?(e.consume(a),i=4,r.interrupt?t:I):n(a)}function p(i){return i===45?(e.consume(i),r.interrupt?t:I):n(i)}function m(i){return i===`CDATA[`.charCodeAt(s++)?(e.consume(i),s===6?r.interrupt?t:O:m):n(i)}function h(t){return W(t)?(e.consume(t),o=String.fromCharCode(t),g):n(t)}function g(s){if(s===null||s===47||s===62||q(s)){let c=s===47,l=o.toLowerCase();return!c&&!a&&Cn.includes(l)?(i=1,r.interrupt?t(s):O(s)):Sn.includes(o.toLowerCase())?(i=6,c?(e.consume(s),_):r.interrupt?t(s):O(s)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?n(s):a?v(s):y(s))}return s===45||G(s)?(e.consume(s),o+=String.fromCharCode(s),g):n(s)}function _(i){return i===62?(e.consume(i),r.interrupt?t:O):n(i)}function v(t){return J(t)?(e.consume(t),v):E(t)}function y(t){return t===47?(e.consume(t),E):t===58||t===95||W(t)?(e.consume(t),b):J(t)?(e.consume(t),y):E(t)}function b(t){return t===45||t===46||t===58||t===95||G(t)?(e.consume(t),b):x(t)}function x(t){return t===61?(e.consume(t),S):J(t)?(e.consume(t),x):y(t)}function S(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),c=t,C):J(t)?(e.consume(t),S):w(t)}function C(t){return t===c?(e.consume(t),c=null,T):t===null||K(t)?n(t):(e.consume(t),C)}function w(t){return t===null||t===34||t===39||t===47||t===60||t===61||t===62||t===96||q(t)?x(t):(e.consume(t),w)}function T(e){return e===47||e===62||J(e)?y(e):n(e)}function E(t){return t===62?(e.consume(t),D):n(t)}function D(t){return t===null||K(t)?O(t):J(t)?(e.consume(t),D):n(t)}function O(t){return t===45&&i===2?(e.consume(t),M):t===60&&i===1?(e.consume(t),N):t===62&&i===4?(e.consume(t),L):t===63&&i===3?(e.consume(t),I):t===93&&i===5?(e.consume(t),F):K(t)&&(i===6||i===7)?(e.exit(`htmlFlowData`),e.check(Tn,R,k)(t)):t===null||K(t)?(e.exit(`htmlFlowData`),k(t)):(e.consume(t),O)}function k(t){return e.check(En,A,R)(t)}function A(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),j}function j(t){return t===null||K(t)?k(t):(e.enter(`htmlFlowData`),O(t))}function M(t){return t===45?(e.consume(t),I):O(t)}function N(t){return t===47?(e.consume(t),o=``,P):O(t)}function P(t){if(t===62){let n=o.toLowerCase();return Cn.includes(n)?(e.consume(t),L):O(t)}return W(t)&&o.length<8?(e.consume(t),o+=String.fromCharCode(t),P):O(t)}function F(t){return t===93?(e.consume(t),I):O(t)}function I(t){return t===62?(e.consume(t),L):t===45&&i===2?(e.consume(t),I):O(t)}function L(t){return t===null||K(t)?(e.exit(`htmlFlowData`),R(t)):(e.consume(t),L)}function R(n){return e.exit(`htmlFlow`),t(n)}}function kn(e,t,n){let r=this;return i;function i(t){return K(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a):n(t)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}function An(e,t,n){return r;function r(r){return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),e.attempt(jt,t,n)}}var jn={name:`htmlText`,tokenize:Mn};function Mn(e,t,n){let r=this,i,a,o;return s;function s(t){return e.enter(`htmlText`),e.enter(`htmlTextData`),e.consume(t),c}function c(t){return t===33?(e.consume(t),l):t===47?(e.consume(t),x):t===63?(e.consume(t),y):W(t)?(e.consume(t),w):n(t)}function l(t){return t===45?(e.consume(t),u):t===91?(e.consume(t),a=0,m):W(t)?(e.consume(t),v):n(t)}function u(t){return t===45?(e.consume(t),p):n(t)}function d(t){return t===null?n(t):t===45?(e.consume(t),f):K(t)?(o=d,N(t)):(e.consume(t),d)}function f(t){return t===45?(e.consume(t),p):d(t)}function p(e){return e===62?M(e):e===45?f(e):d(e)}function m(t){return t===`CDATA[`.charCodeAt(a++)?(e.consume(t),a===6?h:m):n(t)}function h(t){return t===null?n(t):t===93?(e.consume(t),g):K(t)?(o=h,N(t)):(e.consume(t),h)}function g(t){return t===93?(e.consume(t),_):h(t)}function _(t){return t===62?M(t):t===93?(e.consume(t),_):h(t)}function v(t){return t===null||t===62?M(t):K(t)?(o=v,N(t)):(e.consume(t),v)}function y(t){return t===null?n(t):t===63?(e.consume(t),b):K(t)?(o=y,N(t)):(e.consume(t),y)}function b(e){return e===62?M(e):y(e)}function x(t){return W(t)?(e.consume(t),S):n(t)}function S(t){return t===45||G(t)?(e.consume(t),S):C(t)}function C(t){return K(t)?(o=C,N(t)):J(t)?(e.consume(t),C):M(t)}function w(t){return t===45||G(t)?(e.consume(t),w):t===47||t===62||q(t)?T(t):n(t)}function T(t){return t===47?(e.consume(t),M):t===58||t===95||W(t)?(e.consume(t),E):K(t)?(o=T,N(t)):J(t)?(e.consume(t),T):M(t)}function E(t){return t===45||t===46||t===58||t===95||G(t)?(e.consume(t),E):D(t)}function D(t){return t===61?(e.consume(t),O):K(t)?(o=D,N(t)):J(t)?(e.consume(t),D):T(t)}function O(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),i=t,k):K(t)?(o=O,N(t)):J(t)?(e.consume(t),O):(e.consume(t),A)}function k(t){return t===i?(e.consume(t),i=void 0,j):t===null?n(t):K(t)?(o=k,N(t)):(e.consume(t),k)}function A(t){return t===null||t===34||t===39||t===60||t===61||t===96?n(t):t===47||t===62||q(t)?T(t):(e.consume(t),A)}function j(e){return e===47||e===62||q(e)?T(e):n(e)}function M(r){return r===62?(e.consume(r),e.exit(`htmlTextData`),e.exit(`htmlText`),t):n(r)}function N(t){return e.exit(`htmlTextData`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),P}function P(t){return J(t)?Y(e,F,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):F(t)}function F(t){return e.enter(`htmlTextData`),o(t)}}var Nn={name:`labelEnd`,resolveAll:Ln,resolveTo:Rn,tokenize:zn},Pn={tokenize:Bn},Fn={tokenize:Vn},In={tokenize:Hn};function Ln(e){let t=-1,n=[];for(;++t=3&&(a===null||K(a))?(e.exit(`thematicBreak`),t(a)):n(a)}function c(t){return t===i?(e.consume(t),r++,c):(e.exit(`thematicBreakSequence`),J(t)?Y(e,s,`whitespace`)(t):s(t))}}var X={continuation:{tokenize:er},exit:nr,name:`list`,tokenize:$n},Zn={partial:!0,tokenize:rr},Qn={partial:!0,tokenize:tr};function $n(e,t,n){let r=this,i=r.events[r.events.length-1],a=i&&i[1].type===`linePrefix`?i[2].sliceSerialize(i[1],!0).length:0,o=0;return s;function s(t){let i=r.containerState.type||(t===42||t===43||t===45?`listUnordered`:`listOrdered`);if(i===`listUnordered`?!r.containerState.marker||t===r.containerState.marker:ut(t)){if(r.containerState.type||(r.containerState.type=i,e.enter(i,{_container:!0})),i===`listUnordered`)return e.enter(`listItemPrefix`),t===42||t===45?e.check(Yn,n,l)(t):l(t);if(!r.interrupt||t===49)return e.enter(`listItemPrefix`),e.enter(`listItemValue`),c(t)}return n(t)}function c(t){return ut(t)&&++o<10?(e.consume(t),c):(!r.interrupt||o<2)&&(r.containerState.marker?t===r.containerState.marker:t===41||t===46)?(e.exit(`listItemValue`),l(t)):n(t)}function l(t){return e.enter(`listItemMarker`),e.consume(t),e.exit(`listItemMarker`),r.containerState.marker=r.containerState.marker||t,e.check(jt,r.interrupt?n:u,e.attempt(Zn,f,d))}function u(e){return r.containerState.initialBlankLine=!0,a++,f(e)}function d(t){return J(t)?(e.enter(`listItemPrefixWhitespace`),e.consume(t),e.exit(`listItemPrefixWhitespace`),f):n(t)}function f(n){return r.containerState.size=a+r.sliceSerialize(e.exit(`listItemPrefix`),!0).length,t(n)}}function er(e,t,n){let r=this;return r.containerState._closeFlow=void 0,e.check(jt,i,a);function i(n){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,Y(e,t,`listItemIndent`,r.containerState.size+1)(n)}function a(n){return r.containerState.furtherBlankLines||!J(n)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,o(n)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(Qn,t,o)(n))}function o(i){return r.containerState._closeFlow=!0,r.interrupt=void 0,Y(e,e.attempt(X,t,n),`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(i)}}function tr(e,t,n){let r=this;return Y(e,i,`listItemIndent`,r.containerState.size+1);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`listItemIndent`&&i[2].sliceSerialize(i[1],!0).length===r.containerState.size?t(e):n(e)}}function nr(e){e.exit(this.containerState.type)}function rr(e,t,n){let r=this;return Y(e,i,`listItemPrefixWhitespace`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:5);function i(e){let i=r.events[r.events.length-1];return!J(e)&&i&&i[1].type===`listItemPrefixWhitespace`?t(e):n(e)}}var ir={name:`setextUnderline`,resolveTo:ar,tokenize:or};function ar(e,t){let n=e.length,r,i,a;for(;n--;)if(e[n][0]===`enter`){if(e[n][1].type===`content`){r=n;break}e[n][1].type===`paragraph`&&(i=n)}else e[n][1].type===`content`&&e.splice(n,1),!a&&e[n][1].type===`definition`&&(a=n);let o={type:`setextHeading`,start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type=`setextHeadingText`,a?(e.splice(i,0,[`enter`,o,t]),e.splice(a+1,0,[`exit`,e[r][1],t]),e[r][1].end={...e[a][1].end}):e[r][1]=o,e.push([`exit`,o,t]),e}function or(e,t,n){let r=this,i;return a;function a(t){let a=r.events.length,s;for(;a--;)if(r.events[a][1].type!==`lineEnding`&&r.events[a][1].type!==`linePrefix`&&r.events[a][1].type!==`content`){s=r.events[a][1].type===`paragraph`;break}return!r.parser.lazy[r.now().line]&&(r.interrupt||s)?(e.enter(`setextHeadingLine`),i=t,o(t)):n(t)}function o(t){return e.enter(`setextHeadingLineSequence`),s(t)}function s(t){return t===i?(e.consume(t),s):(e.exit(`setextHeadingLineSequence`),J(t)?Y(e,c,`lineSuffix`)(t):c(t))}function c(r){return r===null||K(r)?(e.exit(`setextHeadingLine`),t(r)):n(r)}}var sr={tokenize:cr};function cr(e){let t=this,n=e.attempt(jt,r,e.attempt(this.parser.constructs.flowInitial,i,Y(e,e.attempt(this.parser.constructs.flow,i,e.attempt(rn,i)),`linePrefix`)));return n;function r(r){if(r===null){e.consume(r);return}return e.enter(`lineEndingBlank`),e.consume(r),e.exit(`lineEndingBlank`),t.currentConstruct=void 0,n}function i(r){if(r===null){e.consume(r);return}return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),t.currentConstruct=void 0,n}}var lr={resolveAll:pr()},ur=fr(`string`),dr=fr(`text`);function fr(e){return{resolveAll:pr(e===`text`?mr:void 0),tokenize:t};function t(t){let n=this,r=this.parser.constructs[e],i=t.attempt(r,a,o);return a;function a(e){return c(e)?i(e):o(e)}function o(e){if(e===null){t.consume(e);return}return t.enter(`data`),t.consume(e),s}function s(e){return c(e)?(t.exit(`data`),i(e)):(t.consume(e),s)}function c(e){if(e===null)return!0;let t=r[e],i=-1;if(t)for(;++iCr,contentInitial:()=>_r,disable:()=>wr,document:()=>gr,flow:()=>yr,flowInitial:()=>vr,insideSpan:()=>Sr,string:()=>br,text:()=>xr}),gr={42:X,43:X,45:X,48:X,49:X,50:X,51:X,52:X,53:X,54:X,55:X,56:X,57:X,62:Nt},_r={91:pn},vr={[-2]:Gt,[-1]:Gt,32:Gt},yr={35:yn,42:Yn,45:[ir,Yn],60:wn,61:ir,95:Yn,96:Ht,126:Ht},br={38:zt,92:Lt},xr={[-5]:qn,[-4]:qn,[-3]:qn,33:Un,38:zt,42:Tt,60:[kt,jn],91:Gn,92:[_n,Lt],93:Nn,95:Tt,96:Yt},Sr={null:[Tt,lr]},Cr={null:[42,95]},wr={null:[]};function Tr(e,t,n){let r={_bufferIndex:-1,_index:0,line:n&&n.line||1,column:n&&n.column||1,offset:n&&n.offset||0},i={},a=[],o=[],s=[],c={attempt:C(x),check:C(S),consume:v,enter:y,exit:b,interrupt:C(S,{interrupt:!0})},l={code:null,containerState:{},defineSkip:h,events:[],now:m,parser:e,previous:null,sliceSerialize:f,sliceStream:p,write:d},u=t.tokenize.call(l,c);return t.resolveAll&&a.push(t),l;function d(e){return o=H(o,e),g(),o[o.length-1]===null?(w(t,0),l.events=wt(a,l.events,l),l.events):[]}function f(e,t){return Dr(p(e),t)}function p(e){return Er(o,e)}function m(){let{_bufferIndex:e,_index:t,line:n,column:i,offset:a}=r;return{_bufferIndex:e,_index:t,line:n,column:i,offset:a}}function h(e){i[e.line]=e.column,E()}function g(){let e;for(;r._index-1){let e=o[0];typeof e==`string`?o[0]=e.slice(r):o.shift()}a>0&&o.push(e[i].slice(0,a))}return o}function Dr(e,t){let n=-1,r=[],i;for(;++n0){let e=a.tokenStack[a.tokenStack.length-1];(e[1]||Vr).call(a,void 0,e[0])}for(r.position={start:Rr(e.length>0?e[0][1].start:{line:1,column:1,offset:0}),end:Rr(e.length>0?e[e.length-2][1].end:{line:1,column:1,offset:0})},d=-1;++de*t/m(e,t),1),i=[];return n.forEach((e,t)=>{let n=r/e;for(let r=0;r=4352&&e<=4447||e>=11904&&e<=12350||e>=12353&&e<=13311||e>=13312&&e<=19903||e>=19968&&e<=40959||e>=40960&&e<=42191||e>=44032&&e<=55203||e>=63744&&e<=64255||e>=65072&&e<=65103||e>=65280&&e<=65376||e>=65504&&e<=65510||e>=127744&&e<=129791||e>=131072&&e<=262141}function v(e,t){let n=0;for(let t of e)n+=_(t.codePointAt(0)??0)?2:1;if(n<=t)return e;let r=0,i=``;for(let n of e){let e=_(n.codePointAt(0)??0)?2:1;if(r+e>t-1)break;i+=n,r+=e}return`${i}…`}function ee({panes:e,zoomed:t,onFocus:n,onReorder:r}){let i=(0,p.useRef)(null),a=(0,p.useRef)(null),s=(0,p.useRef)(null),c=(0,p.useRef)(!1),[l,u]=(0,p.useState)(null),[d,f]=(0,p.useState)(null),m=t===null&&e.length>1,h=()=>{i.current=null,a.current=null,s.current=null,c.current=!1,u(null),f(null)};return{draggingPane:l,dragOverPane:d,reorderable:m,endPaneDrag:h,onPaneDragStart:(e,t)=>{e.target.closest(`button`)||(n(t),!(e.button!==0||!m)&&(i.current=t,a.current={x:e.clientX,y:e.clientY},c.current=!1,e.currentTarget.setPointerCapture(e.pointerId)))},onPaneDragMove:e=>{let t=i.current,n=a.current;if(t===null||n===null||!c.current&&Math.hypot(e.clientX-n.x,e.clientY-n.y)<4)return;c.current=!0,u(t);let r=document.elementFromPoint(e.clientX,e.clientY)?.closest(`[data-pane-id]`),o=r?Number(r.getAttribute(`data-pane-id`)):null,l=o!==null&&o!==t?o:null;s.current=l,f(l)},onPaneDragEnd:()=>{let t=i.current,n=s.current;t!==null&&c.current&&n!==null&&r(o(e,t,n)),h()}}}var te=`nightcrow.viewer`;function ne(){return globalThis.crypto?.randomUUID?.()||`tab-${Math.floor(Math.random()*2**48).toString(36)}`}function re(){try{let e=sessionStorage.getItem(te);if(e)return e;let t=ne();return sessionStorage.setItem(te,t),t}catch{return ne()}}var y=!1;function b(){return y?!1:(y=!0,!0)}function ie(e){return typeof e==`number`&&Number.isSafeInteger(e)}function x(e){return ie(e)&&e>=0}function S(e){let t;try{t=JSON.parse(e)}catch{return null}if(!t||typeof t!=`object`||Array.isArray(t))return null;let n=t,r;switch(n.type){case`created`:r=x(n.pane)&&x(n.rows)&&x(n.cols)&&(n.client===void 0||x(n.client))&&(n.title===void 0||typeof n.title==`string`);break;case`exited`:r=x(n.pane);break;case`resized`:r=x(n.pane)&&x(n.rows)&&x(n.cols);break;case`hello`:r=x(n.client)&&x(n.panes);break;case`size_owner`:r=typeof n.owned==`boolean`;break;case`error`:r=typeof n.message==`string`;break;case`reordered`:r=Array.isArray(n.order)&&n.order.every(x);break;case`zoomed`:r=n.pane===null||x(n.pane);break;case`pending`:r=x(n.count);break;case`recovery`:r=x(n.pane)&&typeof n.state==`string`&&(n.detail===void 0||typeof n.detail==`string`)&&(n.deadline_epoch===void 0||ie(n.deadline_epoch))&&x(n.attempt);break;default:return null}return r?n:null}function ae(e,t){return!e||e.readyState!==WebSocket.OPEN?!1:(e.send(JSON.stringify(t)),!0)}function C(e){return e.byteLength<4?null:{pane:new DataView(e).getUint32(0,!0),data:new Uint8Array(e,4)}}var oe=`nightcrow.pane.active`;function se(){try{let e=sessionStorage.getItem(oe);if(!e)return{};let t=JSON.parse(e);if(!t||typeof t!=`object`||Array.isArray(t))return{};let n={};for(let[e,r]of Object.entries(t))typeof r==`number`&&Number.isInteger(r)&&(n[e]=r);return n}catch{return{}}}function ce(e){try{sessionStorage.setItem(oe,JSON.stringify(e))}catch{}}function w(e){return se()[e]}function le(e,t){let n=se();n[e]!==t&&(n[e]=t,ce(n))}function ue(e,t){let n=se();n[e]===t&&(delete n[e],ce(n))}function de(e,t){return t.state===`cancelled`?fe(e,t.pane):{...e,[t.pane]:{state:t.state,detail:t.detail,deadlineEpoch:t.deadline_epoch,attempt:t.attempt}}}function fe(e,t){if(!(t in e))return e;let n={...e};return delete n[t],n}function pe(e,t){return Object.keys(e).map(Number).filter(e=>!t.includes(e)).sort((e,t)=>e-t)}function T(e){if(e===void 0||!Number.isFinite(e))return;let t=new Date(e*1e3);if(!Number.isNaN(t.getTime()))return`${String(t.getHours()).padStart(2,`0`)}:${String(t.getMinutes()).padStart(2,`0`)}`}function me(e){let t=T(e.deadlineEpoch),n=[e.state];return t&&n.push(`until ${t}`),e.attempt>0&&n.push(`attempt ${e.attempt}`),n.join(` · `)}function E(e,t){if(typeof e==`string`){let n=S(e);n&&he(n,t);return}if(!(e instanceof ArrayBuffer))return;let n=C(e);if(!n)return;let r=t.viewsRef.current.get(n.pane);if(r){r.term.write(n.data);return}let i=t.pendingRef.current.get(n.pane)??[];i.push(n.data),t.pendingRef.current.set(n.pane,i)}function he(e,t){switch(e.type){case`hello`:t.clientIdRef.current=e.client,t.setLink(`live`),t.setReplayLeft(e.panes);return;case`pending`:t.setPending(e.count);return;case`created`:{let n=e.pane;t.ptySizesRef.current.set(n,{rows:e.rows,cols:e.cols});let r=e.title;r&&t.setTitles(e=>({...e,[n]:r})),t.setPanes(e=>[...e,n]),t.setReplayLeft(e=>e>0?e-1:0),e.client!=null&&e.client===t.clientIdRef.current?(t.setActive(n),le(t.repo,n)):w(t.repo)===n&&t.setActive(n);return}case`exited`:t.setPanes(t=>t.filter(t=>t!==e.pane)),t.setActive(t=>t===e.pane?null:t),t.pendingRef.current.delete(e.pane),t.ptySizesRef.current.delete(e.pane),t.askedSizesRef.current.delete(e.pane),ue(t.repo,e.pane),t.setTitles(t=>{if(!(e.pane in t))return t;let n={...t};return delete n[e.pane],n});return;case`resized`:t.ptySizesRef.current.set(e.pane,{rows:e.rows,cols:e.cols}),t.viewsRef.current.get(e.pane)?.term.resize(e.cols,e.rows);return;case`recovery`:t.setRecovery(t=>de(t,e));return;case`size_owner`:e.owned&&t.askedSizesRef.current.clear(),t.setOwnsSize(e.owned);return;case`reordered`:t.setPanes(t=>d(t,e.order));return;case`zoomed`:t.zoomAskedRef.current=void 0,t.setZoomed(e.pane??null);return;case`error`:r.error(e.message);return}return e}function ge({repo:e,socketRef:t,viewsRef:n,pendingRef:r,ptySizesRef:i,askedSizesRef:a,zoomAskedRef:o,setLink:s,setPending:c,setReplayLeft:l,setPanes:u,setActive:d,setZoomed:f,setTitles:m,setOwnsSize:h,setRecovery:g}){let _=(0,p.useRef)(null);(0,p.useLayoutEffect)(()=>{let p=!1,v,ee=!1,te=e=>{e===`live`&&(ee=!0),s(e)},ne=()=>ee?`reconnecting`:`connecting`,y={repo:e,clientIdRef:_,viewsRef:n,pendingRef:r,ptySizesRef:i,askedSizesRef:a,zoomAskedRef:o,setLink:te,setPending:c,setReplayLeft:l,setPanes:u,setActive:d,setZoomed:f,setTitles:m,setOwnsSize:h,setRecovery:g},ie=()=>{n.current.forEach(e=>e.term.dispose()),n.current.clear(),r.current.clear(),i.current.clear(),a.current.clear()},x=()=>{_.current=null,te(ne()),l(0),o.current=void 0,c(null),u([]),d(null),f(null),m({});let n=b();n&&h(!0),g({}),ie();let r=location.protocol===`https:`?`wss:`:`ws:`,i=new URLSearchParams({repo:e,viewer:re()});n&&i.set(`claim`,`1`);let a=new WebSocket(`${r}//${location.host}/ws/term?${i}`);a.binaryType=`arraybuffer`,t.current=a,a.onmessage=e=>{t.current===a&&E(e.data,y)},a.onclose=()=>{p||(te(ne()),v=setTimeout(x,1e3))}};return x(),()=>{p=!0,v&&clearTimeout(v),t.current?.close(),ie()}},[e])}var _e=Object.defineProperty,ve=Object.getOwnPropertyDescriptor,ye=(e,t)=>{for(var n in t)_e(e,n,{get:t[n],enumerable:!0})},D=(e,t,n,r)=>{for(var i=r>1?void 0:r?ve(t,n):t,a=e.length-1,o;a>=0;a--)(o=e[a])&&(i=(r?o(t,n,i):o(i))||i);return r&&i&&_e(t,n,i),i},O=(e,t)=>(n,r)=>t(n,r,e),be=`Terminal input`,xe={get:()=>be,set:e=>be=e},Se=`Too much output to announce, navigate to rows manually to read`,Ce={get:()=>Se,set:e=>Se=e};function we(e){return e.replace(/\r?\n/g,`\r`)}function Te(e,t){return t?`\x1B[200~`+e+`\x1B[201~`:e}function Ee(e,t){e.clipboardData&&e.clipboardData.setData(`text/plain`,t.selectionText),e.preventDefault()}function De(e,t,n,r){e.stopPropagation(),e.clipboardData&&Oe(e.clipboardData.getData(`text/plain`),t,n,r)}function Oe(e,t,n,r){e=we(e),e=Te(e,n.decPrivateModes.bracketedPasteMode&&r.rawOptions.ignoreBracketedPasteMode!==!0),n.triggerDataEvent(e,!0),t.value=``}function ke(e,t,n){let r=n.getBoundingClientRect(),i=e.clientX-r.left-10,a=e.clientY-r.top-10;t.style.width=`20px`,t.style.height=`20px`,t.style.left=`${i}px`,t.style.top=`${a}px`,t.style.zIndex=`1000`,t.focus()}function Ae(e,t,n,r,i){ke(e,t,n),i&&r.rightClickSelect(e),t.value=r.selectionText,t.select()}function je(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}function Me(e,t=0,n=e.length){let r=``;for(let i=t;i65535?(t-=65536,r+=String.fromCharCode((t>>10)+55296)+String.fromCharCode(t%1024+56320)):r+=String.fromCharCode(t)}return r}var Ne=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i=0;if(this._interim){let n=e.charCodeAt(i++);56320<=n&&n<=57343?t[r++]=(this._interim-55296)*1024+n-56320+65536:(t[r++]=this._interim,t[r++]=n),this._interim=0}for(let a=i;a=n)return this._interim=i,r;let o=e.charCodeAt(a);56320<=o&&o<=57343?t[r++]=(i-55296)*1024+o-56320+65536:(t[r++]=i,t[r++]=o);continue}i!==65279&&(t[r++]=i)}return r}},Pe=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i,a,o,s,c=0,l=0;if(this.interim[0]){let i=!1,a=this.interim[0];a&=(a&224)==192?31:(a&240)==224?15:7;let o=0,s;for(;(s=this.interim[++o]&63)&&o<4;)a<<=6,a|=s;let c=(this.interim[0]&224)==192?2:(this.interim[0]&240)==224?3:4,u=c-o;for(;l=n)return 0;if(s=e[l++],(s&192)!=128){l--,i=!0;break}else this.interim[o++]=s,a<<=6,a|=s&63}i||(c===2?a<128?l--:t[r++]=a:c===3?a<2048||a>=55296&&a<=57343||a===65279||(t[r++]=a):a<65536||a>1114111||(t[r++]=a)),this.interim.fill(0)}let u=n-4,d=l;for(;d=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(c=(i&31)<<6|a&63,c<128){d--;continue}t[r++]=c}else if((i&240)==224){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(c=(i&15)<<12|(a&63)<<6|o&63,c<2048||c>=55296&&c<=57343||c===65279)continue;t[r++]=c}else if((i&248)==240){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,this.interim[2]=o,r;if(s=e[d++],(s&192)!=128){d--;continue}if(c=(i&7)<<18|(a&63)<<12|(o&63)<<6|s&63,c<65536||c>1114111)continue;t[r++]=c}}return r}},Fe=``,Ie=` `,Le=class e{constructor(){this.fg=0,this.bg=0,this.extended=new Re}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let t=new e;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)==50331648}isBgRGB(){return(this.bg&50331648)==50331648}isFgPalette(){return(this.fg&50331648)==16777216||(this.fg&50331648)==33554432}isBgPalette(){return(this.bg&50331648)==16777216||(this.bg&50331648)==33554432}isFgDefault(){return(this.fg&50331648)==0}isBgDefault(){return(this.bg&50331648)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==16777216||(this.extended.underlineColor&50331648)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},Re=class e{constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new e(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},ze=class e extends Le{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new Re,this.combinedData=``}static fromCharData(t){let n=new e;return n.setFromCharData(t),n}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?je(this.content&2097151):``}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let n=e[1].charCodeAt(0);if(55296<=n&&n<=56319){let r=e[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(n-55296)*1024+r-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},Be=`di$target`,Ve=`di$dependencies`,He=new Map;function Ue(e){return e[Ve]||[]}function k(e){if(He.has(e))return He.get(e);let t=function(e,n,r){if(arguments.length!==3)throw Error(`@IServiceName-decorator can only be used to decorate a parameter`);We(t,e,r)};return t._id=e,He.set(e,t),t}function We(e,t,n){t[Be]===t?t[Ve].push({id:e,index:n}):(t[Ve]=[{id:e,index:n}],t[Be]=t)}var Ge=k(`BufferService`),Ke=k(`CoreMouseService`),qe=k(`CoreService`),Je=k(`CharsetService`),Ye=k(`InstantiationService`),Xe=k(`LogService`),Ze=k(`OptionsService`),Qe=k(`OscLinkService`),$e=k(`UnicodeService`),et=k(`DecorationService`),tt=class{constructor(e,t,n){this._bufferService=e,this._optionsService=t,this._oscLinkService=n}provideLinks(e,t){let n=this._bufferService.buffer.lines.get(e-1);if(!n){t(void 0);return}let r=[],i=this._optionsService.rawOptions.linkHandler,a=new ze,o=n.getTrimmedLength(),s=-1,c=-1,l=!1;for(let t=0;ti?i.activate(e,t,a):nt(e,t),hover:(e,t)=>i?.hover?.(e,t,a),leave:(e,t)=>i?.leave?.(e,t,a)})}l=!1,a.hasExtendedAttrs()&&a.extended.urlId?(c=t,s=a.extended.urlId):(c=-1,s=-1)}}t(r)}};tt=D([O(0,Ge),O(1,Ze),O(2,Qe)],tt);function nt(e,t){if(confirm(`Do you want to navigate to ${t}? +import{a as e,c as t,d as n,f as r,i,l as a,n as o,o as s,p as c,r as l,s as u,t as d,u as f}from"./index-DoNXZFdA.js";var p=c();function m(e,t){for(;t;)[e,t]=[t,e%t];return e}function h(e,t){switch(e){case 1:return[1];case 2:return t?[2]:[1,1];case 3:return[2,1];case 4:return[2,2];case 5:return[3,2];case 6:return[3,3];case 7:return[4,3];default:return[4,4]}}function g(e,t){let n=h(e,t),r=n.reduce((e,t)=>e*t/m(e,t),1),i=[];return n.forEach((e,t)=>{let n=r/e;for(let r=0;r=4352&&e<=4447||e>=11904&&e<=12350||e>=12353&&e<=13311||e>=13312&&e<=19903||e>=19968&&e<=40959||e>=40960&&e<=42191||e>=44032&&e<=55203||e>=63744&&e<=64255||e>=65072&&e<=65103||e>=65280&&e<=65376||e>=65504&&e<=65510||e>=127744&&e<=129791||e>=131072&&e<=262141}function v(e,t){let n=0;for(let t of e)n+=_(t.codePointAt(0)??0)?2:1;if(n<=t)return e;let r=0,i=``;for(let n of e){let e=_(n.codePointAt(0)??0)?2:1;if(r+e>t-1)break;i+=n,r+=e}return`${i}…`}function ee({panes:e,zoomed:t,onFocus:n,onReorder:r}){let i=(0,p.useRef)(null),a=(0,p.useRef)(null),s=(0,p.useRef)(null),c=(0,p.useRef)(!1),[l,u]=(0,p.useState)(null),[d,f]=(0,p.useState)(null),m=t===null&&e.length>1,h=()=>{i.current=null,a.current=null,s.current=null,c.current=!1,u(null),f(null)};return{draggingPane:l,dragOverPane:d,reorderable:m,endPaneDrag:h,onPaneDragStart:(e,t)=>{e.target.closest(`button`)||(n(t),!(e.button!==0||!m)&&(i.current=t,a.current={x:e.clientX,y:e.clientY},c.current=!1,e.currentTarget.setPointerCapture(e.pointerId)))},onPaneDragMove:e=>{let t=i.current,n=a.current;if(t===null||n===null||!c.current&&Math.hypot(e.clientX-n.x,e.clientY-n.y)<4)return;c.current=!0,u(t);let r=document.elementFromPoint(e.clientX,e.clientY)?.closest(`[data-pane-id]`),o=r?Number(r.getAttribute(`data-pane-id`)):null,l=o!==null&&o!==t?o:null;s.current=l,f(l)},onPaneDragEnd:()=>{let t=i.current,n=s.current;t!==null&&c.current&&n!==null&&r(o(e,t,n)),h()}}}var te=`nightcrow.viewer`;function ne(){return globalThis.crypto?.randomUUID?.()||`tab-${Math.floor(Math.random()*2**48).toString(36)}`}function re(){try{let e=sessionStorage.getItem(te);if(e)return e;let t=ne();return sessionStorage.setItem(te,t),t}catch{return ne()}}var y=!1;function b(){return y?!1:(y=!0,!0)}function ie(e){return typeof e==`number`&&Number.isSafeInteger(e)}function x(e){return ie(e)&&e>=0}function S(e){let t;try{t=JSON.parse(e)}catch{return null}if(!t||typeof t!=`object`||Array.isArray(t))return null;let n=t,r;switch(n.type){case`created`:r=x(n.pane)&&x(n.rows)&&x(n.cols)&&(n.client===void 0||x(n.client))&&(n.title===void 0||typeof n.title==`string`);break;case`exited`:r=x(n.pane);break;case`resized`:r=x(n.pane)&&x(n.rows)&&x(n.cols);break;case`hello`:r=x(n.client)&&x(n.panes);break;case`size_owner`:r=typeof n.owned==`boolean`;break;case`error`:r=typeof n.message==`string`;break;case`reordered`:r=Array.isArray(n.order)&&n.order.every(x);break;case`zoomed`:r=n.pane===null||x(n.pane);break;case`pending`:r=x(n.count);break;case`recovery`:r=x(n.pane)&&typeof n.state==`string`&&(n.detail===void 0||typeof n.detail==`string`)&&(n.deadline_epoch===void 0||ie(n.deadline_epoch))&&x(n.attempt);break;default:return null}return r?n:null}function ae(e,t){return!e||e.readyState!==WebSocket.OPEN?!1:(e.send(JSON.stringify(t)),!0)}function C(e){return e.byteLength<4?null:{pane:new DataView(e).getUint32(0,!0),data:new Uint8Array(e,4)}}var oe=`nightcrow.pane.active`;function se(){try{let e=sessionStorage.getItem(oe);if(!e)return{};let t=JSON.parse(e);if(!t||typeof t!=`object`||Array.isArray(t))return{};let n={};for(let[e,r]of Object.entries(t))typeof r==`number`&&Number.isInteger(r)&&(n[e]=r);return n}catch{return{}}}function ce(e){try{sessionStorage.setItem(oe,JSON.stringify(e))}catch{}}function w(e){return se()[e]}function le(e,t){let n=se();n[e]!==t&&(n[e]=t,ce(n))}function ue(e,t){let n=se();n[e]===t&&(delete n[e],ce(n))}function de(e,t){return t.state===`cancelled`?fe(e,t.pane):{...e,[t.pane]:{state:t.state,detail:t.detail,deadlineEpoch:t.deadline_epoch,attempt:t.attempt}}}function fe(e,t){if(!(t in e))return e;let n={...e};return delete n[t],n}function pe(e,t){return Object.keys(e).map(Number).filter(e=>!t.includes(e)).sort((e,t)=>e-t)}function T(e){if(e===void 0||!Number.isFinite(e))return;let t=new Date(e*1e3);if(!Number.isNaN(t.getTime()))return`${String(t.getHours()).padStart(2,`0`)}:${String(t.getMinutes()).padStart(2,`0`)}`}function me(e){let t=T(e.deadlineEpoch),n=[e.state];return t&&n.push(`until ${t}`),e.attempt>0&&n.push(`attempt ${e.attempt}`),n.join(` · `)}function E(e,t){if(typeof e==`string`){let n=S(e);n&&he(n,t);return}if(!(e instanceof ArrayBuffer))return;let n=C(e);if(!n)return;let r=t.viewsRef.current.get(n.pane);if(r){r.term.write(n.data);return}let i=t.pendingRef.current.get(n.pane)??[];i.push(n.data),t.pendingRef.current.set(n.pane,i)}function he(e,t){switch(e.type){case`hello`:t.clientIdRef.current=e.client,t.setLink(`live`),t.setReplayLeft(e.panes);return;case`pending`:t.setPending(e.count);return;case`created`:{let n=e.pane;t.ptySizesRef.current.set(n,{rows:e.rows,cols:e.cols});let r=e.title;r&&t.setTitles(e=>({...e,[n]:r})),t.setPanes(e=>[...e,n]),t.setReplayLeft(e=>e>0?e-1:0),e.client!=null&&e.client===t.clientIdRef.current?(t.setActive(n),le(t.repo,n)):w(t.repo)===n&&t.setActive(n);return}case`exited`:t.setPanes(t=>t.filter(t=>t!==e.pane)),t.setActive(t=>t===e.pane?null:t),t.pendingRef.current.delete(e.pane),t.ptySizesRef.current.delete(e.pane),t.askedSizesRef.current.delete(e.pane),ue(t.repo,e.pane),t.setTitles(t=>{if(!(e.pane in t))return t;let n={...t};return delete n[e.pane],n});return;case`resized`:t.ptySizesRef.current.set(e.pane,{rows:e.rows,cols:e.cols}),t.viewsRef.current.get(e.pane)?.term.resize(e.cols,e.rows);return;case`recovery`:t.setRecovery(t=>de(t,e));return;case`size_owner`:e.owned&&t.askedSizesRef.current.clear(),t.setOwnsSize(e.owned);return;case`reordered`:t.setPanes(t=>d(t,e.order));return;case`zoomed`:t.zoomAskedRef.current=void 0,t.setZoomed(e.pane??null);return;case`error`:r.error(e.message);return}return e}function ge({repo:e,socketRef:t,viewsRef:n,pendingRef:r,ptySizesRef:i,askedSizesRef:a,zoomAskedRef:o,setLink:s,setPending:c,setReplayLeft:l,setPanes:u,setActive:d,setZoomed:f,setTitles:m,setOwnsSize:h,setRecovery:g}){let _=(0,p.useRef)(null);(0,p.useLayoutEffect)(()=>{let p=!1,v,ee=!1,te=e=>{e===`live`&&(ee=!0),s(e)},ne=()=>ee?`reconnecting`:`connecting`,y={repo:e,clientIdRef:_,viewsRef:n,pendingRef:r,ptySizesRef:i,askedSizesRef:a,zoomAskedRef:o,setLink:te,setPending:c,setReplayLeft:l,setPanes:u,setActive:d,setZoomed:f,setTitles:m,setOwnsSize:h,setRecovery:g},ie=()=>{n.current.forEach(e=>e.term.dispose()),n.current.clear(),r.current.clear(),i.current.clear(),a.current.clear()},x=()=>{_.current=null,te(ne()),l(0),o.current=void 0,c(null),u([]),d(null),f(null),m({});let n=b();n&&h(!0),g({}),ie();let r=location.protocol===`https:`?`wss:`:`ws:`,i=new URLSearchParams({repo:e,viewer:re()});n&&i.set(`claim`,`1`);let a=new WebSocket(`${r}//${location.host}/ws/term?${i}`);a.binaryType=`arraybuffer`,t.current=a,a.onmessage=e=>{t.current===a&&E(e.data,y)},a.onclose=()=>{p||(te(ne()),v=setTimeout(x,1e3))}};return x(),()=>{p=!0,v&&clearTimeout(v),t.current?.close(),ie()}},[e])}var _e=Object.defineProperty,ve=Object.getOwnPropertyDescriptor,ye=(e,t)=>{for(var n in t)_e(e,n,{get:t[n],enumerable:!0})},D=(e,t,n,r)=>{for(var i=r>1?void 0:r?ve(t,n):t,a=e.length-1,o;a>=0;a--)(o=e[a])&&(i=(r?o(t,n,i):o(i))||i);return r&&i&&_e(t,n,i),i},O=(e,t)=>(n,r)=>t(n,r,e),be=`Terminal input`,xe={get:()=>be,set:e=>be=e},Se=`Too much output to announce, navigate to rows manually to read`,Ce={get:()=>Se,set:e=>Se=e};function we(e){return e.replace(/\r?\n/g,`\r`)}function Te(e,t){return t?`\x1B[200~`+e+`\x1B[201~`:e}function Ee(e,t){e.clipboardData&&e.clipboardData.setData(`text/plain`,t.selectionText),e.preventDefault()}function De(e,t,n,r){e.stopPropagation(),e.clipboardData&&Oe(e.clipboardData.getData(`text/plain`),t,n,r)}function Oe(e,t,n,r){e=we(e),e=Te(e,n.decPrivateModes.bracketedPasteMode&&r.rawOptions.ignoreBracketedPasteMode!==!0),n.triggerDataEvent(e,!0),t.value=``}function ke(e,t,n){let r=n.getBoundingClientRect(),i=e.clientX-r.left-10,a=e.clientY-r.top-10;t.style.width=`20px`,t.style.height=`20px`,t.style.left=`${i}px`,t.style.top=`${a}px`,t.style.zIndex=`1000`,t.focus()}function Ae(e,t,n,r,i){ke(e,t,n),i&&r.rightClickSelect(e),t.value=r.selectionText,t.select()}function je(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}function Me(e,t=0,n=e.length){let r=``;for(let i=t;i65535?(t-=65536,r+=String.fromCharCode((t>>10)+55296)+String.fromCharCode(t%1024+56320)):r+=String.fromCharCode(t)}return r}var Ne=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i=0;if(this._interim){let n=e.charCodeAt(i++);56320<=n&&n<=57343?t[r++]=(this._interim-55296)*1024+n-56320+65536:(t[r++]=this._interim,t[r++]=n),this._interim=0}for(let a=i;a=n)return this._interim=i,r;let o=e.charCodeAt(a);56320<=o&&o<=57343?t[r++]=(i-55296)*1024+o-56320+65536:(t[r++]=i,t[r++]=o);continue}i!==65279&&(t[r++]=i)}return r}},Pe=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i,a,o,s,c=0,l=0;if(this.interim[0]){let i=!1,a=this.interim[0];a&=(a&224)==192?31:(a&240)==224?15:7;let o=0,s;for(;(s=this.interim[++o]&63)&&o<4;)a<<=6,a|=s;let c=(this.interim[0]&224)==192?2:(this.interim[0]&240)==224?3:4,u=c-o;for(;l=n)return 0;if(s=e[l++],(s&192)!=128){l--,i=!0;break}else this.interim[o++]=s,a<<=6,a|=s&63}i||(c===2?a<128?l--:t[r++]=a:c===3?a<2048||a>=55296&&a<=57343||a===65279||(t[r++]=a):a<65536||a>1114111||(t[r++]=a)),this.interim.fill(0)}let u=n-4,d=l;for(;d=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(c=(i&31)<<6|a&63,c<128){d--;continue}t[r++]=c}else if((i&240)==224){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(c=(i&15)<<12|(a&63)<<6|o&63,c<2048||c>=55296&&c<=57343||c===65279)continue;t[r++]=c}else if((i&248)==240){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,this.interim[2]=o,r;if(s=e[d++],(s&192)!=128){d--;continue}if(c=(i&7)<<18|(a&63)<<12|(o&63)<<6|s&63,c<65536||c>1114111)continue;t[r++]=c}}return r}},Fe=``,Ie=` `,Le=class e{constructor(){this.fg=0,this.bg=0,this.extended=new Re}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let t=new e;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)==50331648}isBgRGB(){return(this.bg&50331648)==50331648}isFgPalette(){return(this.fg&50331648)==16777216||(this.fg&50331648)==33554432}isBgPalette(){return(this.bg&50331648)==16777216||(this.bg&50331648)==33554432}isFgDefault(){return(this.fg&50331648)==0}isBgDefault(){return(this.bg&50331648)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==16777216||(this.extended.underlineColor&50331648)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},Re=class e{constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new e(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},ze=class e extends Le{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new Re,this.combinedData=``}static fromCharData(t){let n=new e;return n.setFromCharData(t),n}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?je(this.content&2097151):``}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let n=e[1].charCodeAt(0);if(55296<=n&&n<=56319){let r=e[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(n-55296)*1024+r-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},Be=`di$target`,Ve=`di$dependencies`,He=new Map;function Ue(e){return e[Ve]||[]}function k(e){if(He.has(e))return He.get(e);let t=function(e,n,r){if(arguments.length!==3)throw Error(`@IServiceName-decorator can only be used to decorate a parameter`);We(t,e,r)};return t._id=e,He.set(e,t),t}function We(e,t,n){t[Be]===t?t[Ve].push({id:e,index:n}):(t[Ve]=[{id:e,index:n}],t[Be]=t)}var Ge=k(`BufferService`),Ke=k(`CoreMouseService`),qe=k(`CoreService`),Je=k(`CharsetService`),Ye=k(`InstantiationService`),Xe=k(`LogService`),Ze=k(`OptionsService`),Qe=k(`OscLinkService`),$e=k(`UnicodeService`),et=k(`DecorationService`),tt=class{constructor(e,t,n){this._bufferService=e,this._optionsService=t,this._oscLinkService=n}provideLinks(e,t){let n=this._bufferService.buffer.lines.get(e-1);if(!n){t(void 0);return}let r=[],i=this._optionsService.rawOptions.linkHandler,a=new ze,o=n.getTrimmedLength(),s=-1,c=-1,l=!1;for(let t=0;ti?i.activate(e,t,a):nt(e,t),hover:(e,t)=>i?.hover?.(e,t,a),leave:(e,t)=>i?.leave?.(e,t,a)})}l=!1,a.hasExtendedAttrs()&&a.extended.urlId?(c=t,s=a.extended.urlId):(c=-1,s=-1)}}t(r)}};tt=D([O(0,Ge),O(1,Ze),O(2,Qe)],tt);function nt(e,t){if(confirm(`Do you want to navigate to ${t}? WARNING: This link could potentially be dangerous`)){let e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn(`Opening link blocked as opener could not be cleared`)}}var rt=k(`CharSizeService`),it=k(`CoreBrowserService`),at=k(`MouseService`),ot=k(`RenderService`),st=k(`SelectionService`),ct=k(`CharacterJoinerService`),lt=k(`ThemeService`),ut=k(`LinkProviderService`),dt=new class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?_t.isErrorNoTelemetry(e)?new _t(e.message+` diff --git a/viewer-ui/dist/assets/index-B-byyxUS.css b/viewer-ui/dist/assets/index-B-byyxUS.css deleted file mode 100644 index 9de62ddb..00000000 --- a/viewer-ui/dist/assets/index-B-byyxUS.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-px{top:-1px}.top-0{top:0}.top-1{top:var(--spacing)}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-1{right:var(--spacing)}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-1{margin-block:calc(var(--spacing) * -1)}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:var(--spacing)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-11{min-height:calc(var(--spacing) * 11)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[50\%\]{max-width:50%}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-none{flex:none}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.cursor-row-resize{cursor:row-resize}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,1fr\)_auto_auto\]{grid-template-rows:auto minmax(0,1fr) auto auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-\[1ch\]{gap:1ch}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab, red, red)){.bg-added\/10{background-color:color-mix(in oklab, var(--color-added) 10%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-800{background-color:var(--color-ink-800)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab, red, red)){.bg-ink-900\/40{background-color:color-mix(in oklab, var(--color-ink-900) 40%, transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab, red, red)){.bg-removed\/10{background-color:color-mix(in oklab, var(--color-removed) 10%, transparent)}}.bg-white{background-color:var(--color-white)}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[1ch\]{padding-inline:1ch}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.opacity-60{opacity:.6}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media (hover:hover){.hover\:border-accent:hover{border-color:var(--color-accent)}.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab, red, red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-ink-700:active{background-color:var(--color-ink-700)}.active\:text-accent:active{color:var(--color-accent)}.disabled\:cursor-progress:disabled{cursor:progress}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}@media (width>=40rem){.sm\:inline{display:inline}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:inline{display:inline}.md\:inline-flex{display:inline-flex}.md\:h-6{height:calc(var(--spacing) * 6)}.md\:w-6{width:calc(var(--spacing) * 6)}.md\:flex-1{flex:1}.md\:basis-1\/2{flex-basis:50%}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.md\:grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.md\:grid-rows-\[auto_minmax\(0\,var\(--nc-upper\)\)_minmax\(0\,var\(--nc-lower\)\)_auto\]{grid-template-rows:auto minmax(0,var(--nc-upper)) minmax(0,var(--nc-lower)) auto}.md\:flex-row{flex-direction:row}.md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;-webkit-text-decoration:overline double underline;-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;-webkit-text-decoration:overline wavy underline;-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;-webkit-text-decoration:overline dotted underline;-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;-webkit-text-decoration:overline dashed underline;-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}html,body,#root{height:var(--nc-visual-viewport-height,100%)}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/assets/index-DUhsGIkz.js b/viewer-ui/dist/assets/index-DUhsGIkz.js deleted file mode 100644 index 944d4d5b..00000000 --- a/viewer-ui/dist/assets/index-DUhsGIkz.js +++ /dev/null @@ -1,11 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-Do6z-tD8.js","./Markdown-C8LL_u4z.css"])))=>i.map(i=>d[i]); -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ee(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var k=/\/+/g;function te(e,t){return typeof e==`object`&&e&&e.key!=null?ee(``+e.key):t.toString(36)}function A(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function j(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,j(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+te(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(k,`$&/`)+`/`),j(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(k,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&te(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&te(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var ee=new MessageChannel,k=ee.port2;ee.port1.onmessage=D,O=function(){k.postMessage(null)}}else O=function(){_(D,0)};function te(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,te(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=p(),n=d(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1oe||(e.current=ae[oe],ae[oe]=null,oe--)}function I(e,t){oe++,ae[oe]=e.current,e.current=t}var L=P(null),se=P(null),ce=P(null),le=P(null);function ue(e,t){switch(I(ce,t),I(se,e),I(L,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}F(L),I(L,e)}function de(){F(L),F(se),F(ce)}function fe(e){e.memoizedState!==null&&I(le,e);var t=L.current,n=Hd(t,e.type);t!==n&&(I(se,e),I(L,n))}function pe(e){se.current===e&&(F(L),F(se)),le.current===e&&(F(le),Qf._currentValue=ie)}var me,he;function ge(e){if(me===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);me=t&&t[1]||``,he=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{_e=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?ge(n):``}function ye(e,t){switch(e.tag){case 26:case 27:case 5:return ge(e.type);case 16:return ge(`Lazy`);case 13:return e.child!==t&&t!==null?ge(`Suspense Fallback`):ge(`Suspense`);case 19:return ge(`SuspenseList`);case 0:case 15:return ve(e.type,!1);case 11:return ve(e.type.render,!1);case 1:return ve(e.type,!0);case 31:return ge(`Activity`);default:return``}}function be(e){try{var t=``,n=null;do t+=ye(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var xe=Object.prototype.hasOwnProperty,Se=t.unstable_scheduleCallback,Ce=t.unstable_cancelCallback,we=t.unstable_shouldYield,Te=t.unstable_requestPaint,Ee=t.unstable_now,De=t.unstable_getCurrentPriorityLevel,Oe=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,Ae=t.unstable_NormalPriority,je=t.unstable_LowPriority,Me=t.unstable_IdlePriority,Ne=t.log,Pe=t.unstable_setDisableYieldValue,Fe=null,Ie=null;function Le(e){if(typeof Ne==`function`&&Pe(e),Ie&&typeof Ie.setStrictMode==`function`)try{Ie.setStrictMode(Fe,e)}catch{}}var Re=Math.clz32?Math.clz32:Ve,ze=Math.log,Be=Math.LN2;function Ve(e){return e>>>=0,e===0?32:31-(ze(e)/Be|0)|0}var He=256,Ue=262144,We=4194304;function Ge(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ke(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ge(n))):i=Ge(o):i=Ge(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ge(n))):i=Ge(o)):i=Ge(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function qe(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Je(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ye(){var e=We;return We<<=1,!(We&62914560)&&(We=4194304),e}function Xe(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ze(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Qe(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),dn=!1;if(un)try{var fn={};Object.defineProperty(fn,"passive",{get:function(){dn=!0}}),window.addEventListener(`test`,fn,fn),window.removeEventListener(`test`,fn,fn)}catch{dn=!1}var pn=null,mn=null,hn=null;function gn(){if(hn)return hn;var e,t=mn,n=t.length,r,i=`value`in pn?pn.value:pn.textContent,a=i.length;for(e=0;e=Jn),Zn=` `,Qn=!1;function $n(e,t){switch(e){case`keyup`:return Kn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function er(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var tr=!1;function nr(e,t){switch(e){case`compositionend`:return er(t);case`keypress`:return t.which===32?(Qn=!0,Zn):null;case`textInput`:return e=t.data,e===Zn&&Qn?null:e;default:return null}}function rr(e,t){if(tr)return e===`compositionend`||!qn&&$n(e,t)?(e=gn(),hn=mn=pn=null,tr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Tr(n)}}function Dr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Dr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Or(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Lt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Lt(e.document)}return t}function kr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Ar=un&&`documentMode`in document&&11>=document.documentMode,jr=null,Mr=null,Nr=null,Pr=!1;function Fr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Pr||jr==null||jr!==Lt(r)||(r=jr,`selectionStart`in r&&kr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Nr&&wr(Nr,r)||(Nr=r,r=Ed(Mr,`onSelect`),0>=o,i-=o,Di=1<<32-Re(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),z&&ki(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),z&&ki(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return z&&ki(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),z&&ki(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&Oa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Fa(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=mi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=pi(o.type,o.key,o.props,null,e.mode,c),Fa(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=_i(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=Oa(o),b(e,r,o,c)}if(re(o))return h(e,r,o,c);if(A(o)){if(l=A(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Pa(o),c);if(o.$$typeof===C)return b(e,r,na(e,o),c);Ia(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=hi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Na=0;var i=b(e,t,n,r);return Ma=null,i}catch(t){if(t===Sa||t===wa)throw t;var a=li(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ra=La(!0),za=La(!1),Ba=!1;function Va(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ha(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ua(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Wa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,G&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=oi(e),ai(e,null,n),t}return ni(e,r,t,n),oi(e)}function Ga(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,et(e,n)}}function Ka(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var qa=!1;function Ja(){if(qa){var e=pa;if(e!==null)throw e}}function Ya(e,t,n,r){qa=!1;var i=e.updateQueue;Ba=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(J&f)===f:(r&f)===f){f!==0&&f===fa&&(qa=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=m({},d,f);break a;case 2:Ba=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Gl|=o,e.lanes=o,e.memoizedState=d}}function Xa(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Za(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=M.T,s={};M.T=s,Fs(e,!1,t,n);try{var c=i(),l=M.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ps(e,t,ga(c,r),pu(e)):Ps(e,t,r,pu(e))}catch(n){Ps(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{N.p=a,o!==null&&s.types!==null&&(o.types=s.types),M.T=o}}function ws(){}function Ts(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=Es(e).queue;Cs(e,a,t,ie,n===null?ws:function(){return Ds(e),n(r)})}function Es(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ie,baseState:ie,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:ie},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Ds(e){var t=Es(e);t.next===null&&(t=e.alternate.memoizedState),Ps(e,t.next.queue,{},pu())}function Os(){return ta(Qf)}function ks(){return jo().memoizedState}function As(){return jo().memoizedState}function js(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Ua(n);var r=Wa(t,e,n);r!==null&&(hu(r,t,n),Ga(r,t,n)),t={cache:ca()},e.payload=t;return}t=t.return}}function Ms(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Is(e)?Ls(t,n):(n=ri(e,t,n,r),n!==null&&(hu(n,e,r),Rs(n,t,r)))}function Ns(e,t,n){Ps(e,t,n,pu())}function Ps(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Is(e))Ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Cr(s,o))return ni(e,t,i,0),K===null&&ti(),!1}catch{}if(n=ri(e,t,i,r),n!==null)return hu(n,e,r),Rs(n,t,r),!0}return!1}function Fs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Is(e)){if(t)throw Error(i(479))}else t=ri(e,n,r,2),t!==null&&hu(t,e,2)}function Is(e){var t=e.alternate;return e===B||t!==null&&t===B}function Ls(e,t){go=ho=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Rs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,et(e,n)}}var zs={readContext:ta,use:Po,useCallback:H,useContext:H,useEffect:H,useImperativeHandle:H,useLayoutEffect:H,useInsertionEffect:H,useMemo:H,useReducer:H,useRef:H,useState:H,useDebugValue:H,useDeferredValue:H,useTransition:H,useSyncExternalStore:H,useId:H,useHostTransitionStatus:H,useFormState:H,useActionState:H,useOptimistic:H,useMemoCache:H,useCacheRefresh:H};zs.useEffectEvent=H;var Bs={readContext:ta,use:Po,useCallback:function(e,t){return Ao().memoizedState=[e,t===void 0?null:t],e},useContext:ta,useEffect:us,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),cs(4194308,4,gs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return cs(4194308,4,e,t)},useInsertionEffect:function(e,t){cs(4,2,e,t)},useMemo:function(e,t){var n=Ao();t=t===void 0?null:t;var r=e();if(_o){Le(!0);try{e()}finally{Le(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Ao();if(n!==void 0){var i=n(t);if(_o){Le(!0);try{n(t)}finally{Le(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ms.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Ao();return e={current:e},t.memoizedState=e},useState:function(e){e=Ko(e);var t=e.queue,n=Ns.bind(null,B,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:vs,useDeferredValue:function(e,t){return xs(Ao(),e,t)},useTransition:function(){var e=Ko(!1);return e=Cs.bind(null,B,e.queue,!0,!1),Ao().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=B,a=Ao();if(z){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),K===null)throw Error(i(349));J&127||Vo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,us(Uo.bind(null,r,o,e),[e]),r.flags|=2048,os(9,{destroy:void 0},Ho.bind(null,r,o,n,t),null),n},useId:function(){var e=Ao(),t=K.identifierPrefix;if(z){var n=Oi,r=Di;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=vo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[st]=t,o[ct]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Pc(t)}}return U(t),Fc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ce.current,Vi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Pi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[st]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Ri(t,!0)}else e=Bd(e).createTextNode(r),e[st]=t,t.stateNode=e}return U(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Vi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[st]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),e=!1}else n=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(lo(t),t):(lo(t),null);if(t.flags&128)throw Error(i(558))}return U(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Vi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[st]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),a=!1}else a=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(lo(t),t):(lo(t),null)}return lo(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Lc(t,t.updateQueue),U(t),null);case 4:return de(),e===null&&Sd(t.stateNode.containerInfo),U(t),null;case 10:return Yi(t.type),U(t),null;case 19:if(F(uo),r=t.memoizedState,r===null)return U(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Rc(r,!1);else{if(X!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=fo(e),o!==null){for(t.flags|=128,Rc(r,!1),e=o.updateQueue,t.updateQueue=e,Lc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)fi(n,e),n=n.sibling;return I(uo,uo.current&1|2),z&&ki(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ee()>tu&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304)}else{if(!a)if(e=fo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Lc(t,e),Rc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!z)return U(t),null}else 2*Ee()-r.renderingStartTime>tu&&n!==536870912&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(U(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ee(),e.sibling=null,n=uo.current,I(uo,a?n&1|2:n&1),z&&ki(t,r.treeForkCount),e);case 22:case 23:return lo(t),no(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(U(t),t.subtreeFlags&6&&(t.flags|=8192)):U(t),n=t.updateQueue,n!==null&&Lc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&F(va),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Yi(sa),U(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Bc(e,t){switch(Mi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Yi(sa),de(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return pe(t),null;case 31:if(t.memoizedState!==null){if(lo(t),t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(lo(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F(uo),null;case 4:return de(),null;case 10:return Yi(t.type),null;case 22:case 23:return lo(t),no(),e!==null&&F(va),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Yi(sa),null;case 25:return null;default:return null}}function Vc(e,t){switch(Mi(t),t.tag){case 3:Yi(sa),de();break;case 26:case 27:case 5:pe(t);break;case 4:de();break;case 31:t.memoizedState!==null&&lo(t);break;case 13:lo(t);break;case 19:F(uo);break;case 10:Yi(t.type);break;case 22:case 23:lo(t),no(),e!==null&&F(va);break;case 24:Yi(sa)}}function Hc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Uc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Wc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Za(t,n)}catch(t){Z(e,e.return,t)}}}function Gc(e,t,n){n.props=qs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Kc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function qc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Jc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Yc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ct]=t}catch(t){Z(e,e.return,t)}}function Xc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Zc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Xc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=en));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Qc(e,t,n),e=e.sibling;e!==null;)Qc(e,t,n),e=e.sibling}function $c(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for($c(e,t,n),e=e.sibling;e!==null;)$c(e,t,n),e=e.sibling}function el(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[st]=e,t[ct]=n}catch(t){Z(e,e.return,t)}}var tl=!1,nl=!1,rl=!1,il=typeof WeakSet==`function`?WeakSet:Set,al=null;function ol(e,t){if(e=e.containerInfo,Rd=sp,e=Or(e),kr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,al=t;al!==null;)if(t=al,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,al=e;else for(;al!==null;){switch(t=al,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[st]=e,bt(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Er(s,h),v=Er(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,M.T=null,n=lu,lu=null;var o=au,s=su;if(iu=0,ou=au=null,su=0,G&6)throw Error(i(331));var c=G;if(G|=4,Fl(o.current),Dl(o,o.current,s,n),G=c,id(0,!1),Ie&&typeof Ie.onPostCommitFiberRoot==`function`)try{Ie.onPostCommitFiberRoot(Fe,o)}catch{}return!0}finally{N.p=a,M.T=r,Vu(e,t)}}function Wu(e,t,n){t=yi(n,t),t=$s(e.stateNode,t,2),e=Wa(e,t,2),e!==null&&(Ze(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(ru===null||!ru.has(r))){e=yi(n,e),n=ec(2),r=Wa(t,n,2),r!==null&&(tc(n,r,t,e),Ze(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new zl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ul=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,K===e&&(J&n)===n&&(X===4||X===3&&(J&62914560)===J&&300>Ee()-$l?!(G&2)&&Su(e,0):ql|=n,Yl===J&&(Yl=0)),rd(e)}function qu(e,t){t===0&&(t=Ye()),e=ii(e,t),e!==null&&(Ze(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Se(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Re(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=J,a=Ke(r,r===K?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||qe(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ee(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}iu!==0&&iu!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=zt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),bt(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+zt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+zt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+zt(n.imageSizes)+`"]`)):i+=`[href="`+zt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=m({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),bt(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+zt(r)+`"][href="`+zt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=m({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),bt(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=yt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=m({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);bt(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=yt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),bt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=yt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),bt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=ce.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=yt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=yt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=yt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+zt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return m({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),bt(t),e.head.appendChild(t))}function Pf(e){return`[src="`+zt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+zt(n.href)+`"]`);if(r)return t.instance=r,bt(r),r;var a=m({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),bt(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,bt(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),bt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,bt(a),a):(r=n,(a=mf.get(o))&&(r=m({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),bt(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,bt(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),bt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=g()})),v=d(),y=_(),b=class extends Error{status;constructor(e,t){super(t),this.status=e}},x=e=>e instanceof b&&e.status===401,S=class extends Error{constructor(e){super(`connection lost — check your network`,{cause:e}),this.name=`NetworkError`}},C=e=>e instanceof S;async function w(e,t){try{return await fetch(e,t)}catch(e){throw new S(e)}}async function T(e){if(!e.ok){let t=`request failed (${e.status})`;try{let n=await e.json();typeof n?.error==`string`&&(t=n.error)}catch{}throw new b(e.status,t)}let t=await e.json();if(t.version!==2)throw new b(e.status,`this page is out of date (server protocol v${t.version}) — reload`);return t}async function E(e,t){return T(await w(e,{credentials:`same-origin`,signal:t}))}async function D(e,t,n){return T(await w(e,{method:`POST`,credentials:`same-origin`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t),signal:n}))}var O=e=>new URLSearchParams(e).toString(),ee=1e4,k={async login(e){let t=await w(`/login`,{method:`POST`,credentials:`same-origin`,headers:{"Content-Type":`application/x-www-form-urlencoded`},body:new URLSearchParams({password:e}).toString()});if(!t.ok)throw new b(t.status,t.status===429?`too many attempts — wait a minute`:`incorrect password`)},repos:e=>E(`/api/repos`,e),setAccent:e=>D(`/api/prefs`,{accent:e}).then(e=>e.accent),setSidebarWidth:e=>D(`/api/prefs`,{sidebar_width:e}).then(e=>e.sidebar_width),setUpperPct:e=>D(`/api/prefs`,{upper_pct:e}).then(e=>e.upper_pct),setActiveRepo:e=>D(`/api/prefs`,{active_repo:e},AbortSignal.timeout(ee)).then(e=>e.active_repo),setMaximized:(e,t)=>D(`/api/prefs`,{maximized:{repo:e,panel:t}},AbortSignal.timeout(ee)).then(e=>e.maximized),setRepoView:(e,t)=>D(`/api/prefs`,{view:{repo:e,...t}},AbortSignal.timeout(ee)).then(e=>e.last_view),status:e=>E(`/api/status?${O({repo:e})}`),tree:(e,t)=>E(`/api/tree?${O({repo:e,path:t})}`),treeSearch:(e,t)=>E(`/api/tree/search?${O({repo:e,q:t})}`),previewUrl:(e,t,n)=>`/api/preview?${O(n?{repo:e,path:t,oid:n}:{repo:e,path:t})}`,log:(e,t)=>E(`/api/log?${O(t?{repo:e,from:t.from,skip:String(t.skip)}:{repo:e})}`),diff:(e,t)=>E(`/api/diff?${O({repo:e,path:t})}`),file:(e,t)=>E(`/api/file?${O({repo:e,path:t})}`),commit:(e,t)=>E(`/api/commit?${O({repo:e,oid:t})}`),commitFiles:(e,t)=>E(`/api/commit/files?${O({repo:e,oid:t})}`),commitFileDiff:(e,t,n)=>E(`/api/commit/file-diff?${O({repo:e,oid:t,path:n})}`),commitFile:(e,t,n)=>E(`/api/commit/file?${O({repo:e,oid:t,path:n})}`),browse:e=>E(`/api/browse${e?`?${O({path:e})}`:``}`),mkdir:(e,t)=>D(`/api/mkdir`,{path:e,name:t}).then(e=>e.path),clone:(e,t)=>D(`/api/clone`,{path:e,url:t}),cloneStatus:e=>E(`/api/clone?${O({job:String(e)})}`),runningClone:()=>E(`/api/clone`),open:e=>D(`/api/repos`,{path:e}).then(e=>e.repo),close:async e=>{let t=await w(`/api/repos?${O({repo:e})}`,{method:`DELETE`,credentials:`same-origin`});if(!t.ok)throw new b(t.status,`could not close (${t.status})`)},reorderRepos:e=>D(`/api/repos/order`,{order:e}).then(e=>e.repos),reloadConfig:()=>D(`/api/reload`,{}).then(e=>e.summary)};function te(e,t){let n=new EventSource(`/api/events?${O({repo:e})}`);return n.addEventListener(`status`,e=>{try{let n=JSON.parse(e.data);n.version===2&&t(n)}catch{}}),()=>n.close()}var A=4,j=[],ne=1,re=new Set;function M(){let e=j;re.forEach(t=>t(e))}function N(e){return re.add(e),e(j),()=>{re.delete(e)}}function ie(e){let t=j.filter(t=>t.id!==e);t.length!==j.length&&(j=t,M())}function ae(e,t,n={}){let r=j.findIndex(n=>n.kind===e&&n.message===t);if(r!==-1){let e=j[r];return j=j.map((e,t)=>t===r?{...e,...n,bump:e.bump+1}:e),M(),e.id}let i=ne++;return j=oe([...j,{id:i,kind:e,message:t,bump:0,...n}]),M(),i}function oe(e){if(e.length<=A)return e;let t=e[e.length-1],n=e.slice(0,-1);for(;n.length>=A;){let e=n.findIndex(e=>!e.sticky);n.splice(e===-1?0:e,1)}return[...n,t]}var P={error:(e,t)=>ae(`error`,e,t),info:(e,t)=>ae(`info`,e,t),success:(e,t)=>ae(`success`,e,t)},F=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),I=o(((e,t)=>{t.exports=F()})),L=I();function se({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M18 6 6 18`}),(0,L.jsx)(`path`,{d:`m6 6 12 12`})]})}function ce({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M5 12h14`}),(0,L.jsx)(`path`,{d:`M12 5v14`})]})}function le(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`circle`,{cx:`11`,cy:`11`,r:`8`}),(0,L.jsx)(`path`,{d:`m21 21-4.3-4.3`})]})}function ue({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`}),(0,L.jsx)(`path`,{d:`m16 17 5-5-5-5`}),(0,L.jsx)(`path`,{d:`M21 12H9`})]})}function de({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3H21`}),(0,L.jsx)(`path`,{d:`M21 3v6h-6`}),(0,L.jsx)(`path`,{d:`M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3H3`}),(0,L.jsx)(`path`,{d:`M3 21v-6h6`})]})}function fe({onClose:e,onOpened:t,canClone:n,cloning:r,onClone:i}){let[a,o]=(0,v.useState)(null),[s,c]=(0,v.useState)(null),[l,u]=(0,v.useState)(null),[d,f]=(0,v.useState)(!1),[p,m]=(0,v.useState)(``),[h,g]=(0,v.useState)(!1),[_,y]=(0,v.useState)(``),[b,x]=(0,v.useState)(0);(0,v.useEffect)(()=>{let e=!1;return k.browse(a??void 0).then(t=>{e||(c(t),u(null))}).catch(t=>{e||u(t instanceof Error?t.message:`could not browse`)}),()=>{e=!0}},[a,b]);let S=e=>o(`${s.path.replace(/\/$/,``)}/${e}`),C=async()=>{if(s){f(!0);try{t(await k.open(s.path))}catch(e){P.error(e instanceof Error?e.message:`could not open`),f(!1)}}},w=async()=>{if(!s)return;let e=p.trim();if(e){g(!0);try{await k.mkdir(s.path,e),m(``),x(e=>e+1)}catch(e){P.error(e instanceof Error?e.message:`could not create folder`)}finally{g(!1)}}};return(0,L.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4`,onClick:e,children:(0,L.jsxs)(`div`,{className:`flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900`,onClick:e=>e.stopPropagation(),children:[(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2`,children:[(0,L.jsx)(`span`,{className:`font-medium text-ink-50`,children:`Open a project`}),(0,L.jsx)(`button`,{onClick:e,"aria-label":`close`,className:`ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200`,children:(0,L.jsx)(se,{})})]}),(0,L.jsx)(`div`,{className:`shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400`,children:s?.path??`…`}),(0,L.jsxs)(`ul`,{className:`h-72 min-h-0 overflow-y-auto`,children:[s?.parent&&(0,L.jsx)(`li`,{children:(0,L.jsx)(`button`,{onClick:()=>o(s.parent),className:`w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850`,children:`../`})}),s?.entries.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{onClick:()=>S(e.name),className:`flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850`,children:[(0,L.jsxs)(`span`,{className:`truncate text-accent`,children:[e.name,`/`]}),e.is_repo&&(0,L.jsx)(`span`,{className:`rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200`,children:`git`})]})},e.name)),s&&s.entries.length===0&&(0,L.jsx)(`li`,{className:`px-3 py-1 text-ink-400`,children:`No sub-folders.`})]}),l&&(0,L.jsx)(`p`,{className:`shrink-0 px-3 py-1 text-removed`,children:l}),(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,L.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),onKeyDown:e=>{e.key===`Enter`&&w()},placeholder:`New folder name`,"aria-label":`new folder name`,className:`min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none`}),(0,L.jsx)(`button`,{onClick:w,disabled:!s||!p.trim()||h,className:`shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50`,children:h?`Creating…`:`Create`})]}),(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,L.jsx)(`input`,{value:_,onChange:e=>y(e.target.value),onKeyDown:e=>{e.key===`Enter`&&s&&i(s.path,_)},disabled:!n,placeholder:n?`Clone a git URL here`:`git is not installed on the server`,"aria-label":`git URL to clone`,spellCheck:!1,autoCapitalize:`none`,autoCorrect:`off`,className:`min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none disabled:opacity-50`}),(0,L.jsx)(`button`,{onClick:()=>s&&i(s.path,_),disabled:!n||!s||!_.trim()||r,title:n?void 0:`the server has no git on its PATH`,className:`shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50`,children:r?`Cloning…`:`Clone`})]}),(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,L.jsx)(`span`,{className:`truncate text-ink-400`,children:s?s.path:``}),(0,L.jsx)(`button`,{onClick:C,disabled:!s||d,className:`ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50`,children:d?`Opening…`:`Open`})]})]})})}var pe=`data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='utf-8'?%3e%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='12%2057%201150%201150'%20role='img'%20aria-label='Black%20crow'%3e%3ctitle%3eBlack%20crow%3c/title%3e%3cdesc%3eMonochrome%20black%20crow%20silhouette%20on%20a%20transparent%20background,%20framed%20so%20the%20bird%20sits%20centred%20for%20use%20as%20an%20inline%20mark%20on%20a%20square%20tile.%3c/desc%3e%3cg%20fill-rule='evenodd'%20clip-rule='evenodd'%3e%3cpath%20fill='%23000000'%20d='M%20882%20147%20L%20859%20136%20L%20844%20131%20L%20831%20129%20L%20830%20128%20L%20815%20127%20L%20814%20126%20L%20796%20126%20L%20795%20127%20L%20786%20127%20L%20785%20128%20L%20775%20129%20L%20752%20136%20L%20732%20146%20L%20713%20160%20L%20701%20172%20L%20684%20172%20L%20683%20173%20L%20673%20173%20L%20672%20174%20L%20650%20176%20L%20649%20177%20L%20627%20181%20L%20602%20190%20L%20589%20197%20L%20579%20204%20L%20562%20221%20L%20562%20223%20L%20565%20223%20L%20578%20228%20L%20581%20228%20L%20612%20238%20L%20672%20252%20L%20684%20258%20L%20698%20271%20L%20702%20278%20L%20705%20288%20L%20705%20294%20L%20703%20301%20L%20699%20308%20L%20688%20318%20L%20630%20347%20L%20593%20372%20L%20561%20399%20L%20544%20416%20L%20522%20441%20L%20492%20481%20L%20461%20531%20L%20438%20576%20L%20431%20594%20L%20425%20602%20L%20405%20635%20L%20387%20668%20L%20385%20676%20L%20390%20679%20L%20368%20705%20L%20330%20755%20L%20306%20790%20L%20296%20808%20L%20289%20818%20L%20280%20838%20L%20280%20843%20L%20283%20845%20L%20292%20843%20L%20297%20840%20L%20299%20840%20L%20321%20828%20L%20322%20830%20L%20311%20844%20L%20288%20878%20L%20287%20881%20L%20259%20924%20L%20235%20965%20L%20205%201023%20L%20205%201025%20L%20197%201042%20L%20191%201061%20L%20191%201071%20L%20192%201072%20L%20198%201071%20L%20220%201056%20L%20242%201038%20L%20300%20986%20L%20302%20987%20L%20265%201040%20L%20264%201043%20L%20246%201070%20L%20235%201090%20L%20227%201112%20L%20227%201123%20L%20229%201128%20L%20234%201133%20L%20239%201135%20L%20255%201135%20L%20274%201129%20L%20279%201134%20L%20286%201137%20L%20290%201137%20L%20291%201138%20L%20310%201138%20L%20311%201137%20L%20317%201137%20L%20318%201136%20L%20326%201135%20L%20344%201129%20L%20369%201116%20L%20395%201097%20L%20420%201073%20L%20445%201042%20L%20457%201024%20L%20461%201016%20L%20464%201013%20L%20468%201011%20L%20489%20994%20L%20595%20901%20L%20601%20906%20L%20606%20913%20L%20614%20921%20L%20637%20949%20L%20639%20953%20L%20639%20956%20L%20636%20960%20L%20634%20961%20L%20619%20962%20L%20613%20965%20L%20605%20974%20L%20602%20982%20L%20602%20994%20L%20605%201001%20L%20608%201004%20L%20609%201004%20L%20609%20999%20L%20612%20992%20L%20616%20988%20L%20620%20986%20L%20627%20986%20L%20635%20983%20L%20645%20983%20L%20646%20982%20L%20655%20982%20L%20668%20986%20L%20676%20990%20L%20682%20996%20L%20685%201003%20L%20688%201006%20L%20696%201009%20L%20697%201012%20L%20697%201024%20L%20693%201033%20L%20693%201035%20L%20695%201035%20L%20700%201032%20L%20707%201025%20L%20710%201020%20L%20713%201010%20L%20713%201003%20L%20711%20998%20L%20711%20994%20L%20712%20993%20L%20719%201003%20L%20723%201005%20L%20727%201005%20L%20730%201011%20L%20730%201022%20L%20727%201031%20L%20728%201033%20L%20740%201021%20L%20743%201014%20L%20744%201003%20L%20747%20999%20L%20749%20992%20L%20748%20977%20L%20744%20968%20L%20740%20963%20L%20741%20962%20L%20755%20961%20L%20768%20964%20L%20777%20969%20L%20783%20975%20L%20786%20981%20L%20789%20984%20L%20795%20987%20L%20799%20987%20L%20801%20991%20L%20801%20997%20L%20802%20998%20L%20799%201013%20L%20802%201012%20L%20808%201007%20L%20813%201000%20L%20816%20991%20L%20816%20981%20L%20814%20976%20L%20814%20968%20L%20815%20967%20L%20819%20970%20L%20823%20970%20L%20826%20973%20L%20829%20980%20L%20830%20993%20L%20834%20990%20L%20838%20979%20L%20838%20968%20L%20832%20951%20L%20822%20940%20L%20815%20936%20L%20803%20933%20L%20776%20935%20L%20763%20931%20L%20753%20922%20L%20731%20898%20L%20703%20865%20L%20703%20863%20L%20710%20853%20L%20711%20855%20L%20707%20862%20L%20709%20862%20L%20718%20857%20L%20754%20832%20L%20793%20799%20L%20818%20774%20L%20849%20737%20L%20850%20741%20L%20845%20755%20L%20847%20755%20L%20861%20743%20L%20881%20721%20L%20906%20686%20L%20918%20665%20L%20933%20635%20L%20951%20590%20L%20971%20525%20L%20971%20521%20L%20974%20512%20L%20974%20508%20L%20978%20493%20L%20979%20482%20L%20980%20481%20L%20981%20466%20L%20982%20465%20L%20983%20441%20L%20982%20440%20L%20982%20428%20L%20981%20427%20L%20980%20414%20L%20978%20409%20L%20976%20397%20L%20970%20381%20L%20971%20379%20L%20974%20383%20L%20976%20381%20L%20977%20334%20L%20976%20333%20L%20976%20322%20L%20975%20321%20L%20974%20307%20L%20973%20306%20L%20973%20301%20L%20972%20300%20L%20969%20280%20L%20958%20243%20L%20949%20224%20L%20949%20222%20L%20939%20204%20L%20922%20181%20L%20903%20162%20Z%20M%20625%20888%20L%20656%20874%20L%20658%20874%20L%20665%20870%20L%20725%20930%20L%20728%20934%20L%20728%20940%20L%20723%20943%20L%20714%20944%20L%20707%20950%20L%20683%20951%20L%20673%20947%20L%20659%20932%20Z%20M%20787%20182%20L%20792%20182%20L%20796%20187%20L%20795%20192%20L%20791%20195%20L%20788%20195%20L%20783%20191%20L%20783%20186%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20895%20156%20L%20863%20138%20L%20838%20130%20L%20819%20127%20L%20789%20127%20L%20777%20129%20L%20753%20136%20L%20736%20144%20L%20717%20157%20L%20702%20172%20L%20652%20176%20L%20628%20181%20L%20607%20188%20L%20585%20200%20L%20562%20222%20L%20613%20238%20L%20670%20251%20L%20683%20257%20L%20697%20269%20L%20705%20286%20L%20705%20296%20L%20703%20302%20L%20698%20310%20L%20687%20319%20L%20631%20347%20L%20591%20374%20L%20568%20393%20L%20538%20423%20L%20520%20444%20L%20481%20498%20L%20457%20539%20L%20437%20579%20L%20432%20593%20L%20413%20622%20L%20386%20671%20L%20386%20677%20L%20390%20677%20L%20391%20679%20L%20374%20698%20L%20334%20750%20L%20307%20789%20L%20290%20817%20L%20280%20839%20L%20281%20844%20L%20291%20843%20L%20323%20826%20L%20325%20827%20L%20289%20877%20L%20237%20962%20L%20209%201015%20L%20198%201040%20L%20191%201063%20L%20191%201070%20L%20194%201072%20L%20213%201061%20L%20259%201023%20L%20302%20984%20L%20303%20985%20L%20262%201045%20L%20245%201072%20L%20233%201095%20L%20227%201114%20L%20228%201126%20L%20233%201132%20L%20242%201135%20L%20252%201135%20L%20274%201128%20L%20278%201133%20L%20288%201137%20L%20313%201137%20L%20343%201129%20L%20373%201113%20L%20398%201094%20L%20424%201068%20L%20441%201047%20L%20465%201012%20L%20520%20967%20L%20595%20900%20L%20611%20917%20L%20639%20952%20L%20639%20957%20L%20637%20960%20L%20632%20962%20L%20618%20963%20L%20611%20967%20L%20606%20973%20L%20602%20983%20L%20602%20992%20L%20608%201004%20L%20611%20993%20L%20619%20986%20L%20626%20986%20L%20643%20982%20L%20656%20982%20L%20675%20989%20L%20683%20997%20L%20688%201006%20L%20696%201009%20L%20697%201025%20L%20693%201034%20L%20694%201035%20L%20701%201031%20L%20710%201019%20L%20712%201013%20L%20712%20993%20L%20720%201003%20L%20727%201005%20L%20730%201009%20L%20730%201025%20L%20727%201032%20L%20732%201030%20L%20739%201022%20L%20743%201013%20L%20743%201004%20L%20749%20991%20L%20748%20978%20L%20740%20964%20L%20743%20961%20L%20757%20961%20L%20769%20964%20L%20781%20972%20L%20791%20985%20L%20798%20986%20L%20802%20994%20L%20802%201003%20L%20799%201013%20L%20810%201004%20L%20815%20994%20L%20816%20983%20L%20814%20977%20L%20814%20965%20L%20818%20969%20L%20825%20971%20L%20830%20983%20L%20830%20993%20L%20833%20991%20L%20837%20982%20L%20838%20969%20L%20834%20956%20L%20825%20943%20L%20820%20939%20L%20807%20934%20L%20774%20935%20L%20762%20931%20L%20736%20904%20L%20702%20864%20L%20715%20845%20L%20716%20847%20L%20707%20862%20L%20719%20856%20L%20744%20839%20L%20786%20805%20L%20824%20767%20L%20851%20733%20L%20852%20735%20L%20846%20755%20L%20853%20750%20L%20883%20718%20L%20906%20685%20L%20927%20647%20L%20951%20589%20L%20968%20535%20L%20976%20502%20L%20982%20461%20L%20981%20422%20L%20976%20398%20L%20968%20378%20L%20969%20376%20L%20975%20383%20L%20977%20341%20L%20970%20286%20L%20958%20244%20L%20945%20215%20L%20925%20185%20L%20906%20165%20Z%20M%20625%20888%20L%20665%20870%20L%20728%20933%20L%20729%20940%20L%20726%20943%20L%20715%20944%20L%20708%20950%20L%20692%20952%20L%20678%20950%20L%20672%20947%20L%20662%20936%20Z%20M%20785%20183%20L%20792%20182%20L%20796%20186%20L%20796%20191%20L%20791%20195%20L%20785%20194%20L%20783%20191%20L%20783%20186%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20896%20157%20L%20869%20141%20L%20837%20130%20L%20817%20127%20L%20792%20127%20L%20778%20129%20L%20751%20137%20L%20733%20146%20L%20715%20159%20L%20702%20172%20L%20653%20176%20L%20610%20187%20L%20581%20203%20L%20562%20222%20L%20625%20241%20L%20671%20251%20L%20683%20257%20L%20698%20270%20L%20705%20285%20L%20704%20300%20L%20699%20309%20L%20689%20318%20L%20628%20349%20L%20581%20382%20L%20540%20421%20L%20517%20448%20L%20478%20503%20L%20456%20541%20L%20439%20575%20L%20432%20593%20L%20412%20624%20L%20388%20667%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20363%20712%20L%20332%20753%20L%20289%20819%20L%20281%20836%20L%20281%20844%20L%20293%20842%20L%20323%20826%20L%20325%20827%20L%20284%20885%20L%20236%20964%20L%20205%201024%20L%20197%201043%20L%20191%201064%20L%20191%201070%20L%20197%201071%20L%20210%201063%20L%20253%201028%20L%20303%20983%20L%20304%20984%20L%20259%201050%20L%20235%201091%20L%20227%201115%20L%20228%201125%20L%20232%201131%20L%20238%201134%20L%20249%201135%20L%20261%201133%20L%20274%201128%20L%20278%201133%20L%20289%201137%20L%20312%201137%20L%20342%201129%20L%20368%201116%20L%20399%201093%20L%20423%201069%20L%20443%201044%20L%20463%201013%20L%20493%20990%20L%20595%20900%20L%20612%20918%20L%20639%20952%20L%20639%20957%20L%20636%20961%20L%20616%20964%20L%20606%20973%20L%20602%20984%20L%20602%20991%20L%20608%201004%20L%20611%20993%20L%20621%20985%20L%20625%20986%20L%20639%20982%20L%20657%20982%20L%20677%20990%20L%20685%201002%20L%20690%201007%20L%20697%201010%20L%20698%201021%20L%20693%201034%20L%20694%201035%20L%20705%201027%20L%20710%201018%20L%20712%201011%20L%20712%201001%20L%20710%20994%20L%20712%20993%20L%20720%201003%20L%20729%201006%20L%20731%201020%20L%20728%201032%20L%20738%201023%20L%20743%201012%20L%20743%201003%20L%20748%20994%20L%20747%20976%20L%20740%20964%20L%20743%20961%20L%20759%20961%20L%20767%20963%20L%20780%20971%20L%20790%20984%20L%20799%20986%20L%20802%20992%20L%20802%201004%20L%20799%201012%20L%20803%201011%20L%20810%201004%20L%20815%20994%20L%20814%20965%20L%20818%20969%20L%20823%20969%20L%20830%20982%20L%20830%20992%20L%20832%20992%20L%20837%20982%20L%20837%20965%20L%20831%20950%20L%20821%20940%20L%20806%20934%20L%20772%20935%20L%20762%20931%20L%20733%20901%20L%20702%20863%20L%20717%20842%20L%20718%20844%20L%20707%20862%20L%20738%20843%20L%20780%20810%20L%20822%20769%20L%20851%20733%20L%20852%20735%20L%20846%20755%20L%20855%20748%20L%20882%20719%20L%20904%20688%20L%20930%20640%20L%20950%20591%20L%20967%20538%20L%20978%20490%20L%20982%20459%20L%20982%20434%20L%20976%20399%20L%20967%20376%20L%20969%20375%20L%20975%20383%20L%20976%20329%20L%20971%20293%20L%20960%20250%20L%20942%20210%20L%20923%20183%20Z%20M%20625%20888%20L%20665%20870%20L%20729%20934%20L%20729%20940%20L%20726%20943%20L%20716%20944%20L%20708%20950%20L%20695%20952%20L%20677%20950%20L%20666%20941%20Z%20M%20786%20182%20L%20790%20181%20L%20794%20183%20L%20797%20188%20L%20792%20195%20L%20787%20195%20L%20782%20190%20L%20782%20187%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20893%20155%20L%20873%20143%20L%20841%20131%20L%20816%20127%20L%20794%20127%20L%20779%20129%20L%20754%20136%20L%20735%20145%20L%20715%20159%20L%20702%20172%20L%20648%20177%20L%20629%20181%20L%20608%20188%20L%20584%20201%20L%20562%20222%20L%20614%20238%20L%20671%20251%20L%20685%20258%20L%20698%20270%20L%20704%20281%20L%20706%20292%20L%20703%20303%20L%20699%20309%20L%20686%20320%20L%20628%20349%20L%20590%20375%20L%20565%20396%20L%20543%20418%20L%20518%20447%20L%20490%20485%20L%20460%20534%20L%20441%20571%20L%20431%20595%20L%20405%20636%20L%20386%20672%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20371%20702%20L%20335%20749%20L%20291%20816%20L%20280%20840%20L%20282%20844%20L%20298%20840%20L%20324%20825%20L%20326%20826%20L%20294%20870%20L%20235%20966%20L%20206%201022%20L%20198%201041%20L%20191%201065%20L%20191%201070%20L%20196%201071%20L%20214%201060%20L%20254%201027%20L%20303%20983%20L%20304%20984%20L%20256%201055%20L%20232%201098%20L%20227%201115%20L%20228%201125%20L%20232%201131%20L%20239%201134%20L%20248%201135%20L%20264%201132%20L%20274%201128%20L%20280%201134%20L%20289%201137%20L%20311%201137%20L%20339%201130%20L%20371%201114%20L%20401%201091%20L%20422%201070%20L%20440%201048%20L%20463%201013%20L%20487%20995%20L%20595%20900%20L%20613%20919%20L%20639%20951%20L%20639%20958%20L%20636%20961%20L%20619%20963%20L%20613%20966%20L%20605%20975%20L%20602%20991%20L%20604%20998%20L%20608%201003%20L%20611%20993%20L%20620%20985%20L%20624%20986%20L%20632%20983%20L%20647%20981%20L%20658%20982%20L%20677%20990%20L%20689%201006%20L%20697%201009%20L%20698%201021%20L%20694%201035%20L%20703%201029%20L%20710%201018%20L%20712%201010%20L%20712%201002%20L%20710%20997%20L%20711%20992%20L%20720%201003%20L%20728%201005%20L%20730%201008%20L%20731%201021%20L%20728%201032%20L%20738%201023%20L%20742%201014%20L%20743%201003%20L%20748%20994%20L%20748%20980%20L%20742%20966%20L%20739%20963%20L%20741%20961%20L%20753%20960%20L%20770%20964%20L%20780%20971%20L%20790%20984%20L%20799%20986%20L%20802%20992%20L%20802%201005%20L%20799%201012%20L%20804%201010%20L%20809%201005%20L%20815%20993%20L%20813%20964%20L%20817%20968%20L%20823%20969%20L%20826%20972%20L%20830%20982%20L%20831%20992%20L%20834%20989%20L%20838%20975%20L%20837%20966%20L%20831%20950%20L%20821%20940%20L%20804%20934%20L%20779%20936%20L%20763%20932%20L%20731%20899%20L%20702%20865%20L%20702%20863%20L%20717%20842%20L%20718%20844%20L%20708%20862%20L%20749%20835%20L%20781%20809%20L%20820%20771%20L%20851%20733%20L%20852%20736%20L%20846%20755%20L%20857%20746%20L%20881%20720%20L%20907%20683%20L%20928%20644%20L%20945%20604%20L%20967%20537%20L%20978%20489%20L%20982%20455%20L%20982%20437%20L%20979%20413%20L%20975%20396%20L%20967%20377%20L%20968%20374%20L%20975%20382%20L%20976%20332%20L%20972%20299%20L%20962%20257%20L%20954%20235%20L%20943%20212%20L%20923%20183%20Z%20M%20624%20888%20L%20666%20870%20L%20729%20934%20L%20729%20940%20L%20726%20943%20L%20716%20944%20L%20706%20951%20L%20684%20952%20L%20677%20950%20L%20665%20940%20Z%20M%20701%20220%20L%20710%20219%20L%20717%20221%20L%20704%20223%20L%20704%20221%20Z%20M%20666%20217%20L%20679%20216%20L%20689%20218%20L%20685%20220%20L%20675%20220%20Z%20M%20658%20210%20L%20661%20208%20L%20686%20205%20L%20706%20206%20L%20725%20209%20L%20733%20217%20L%20741%20220%20L%20738%20221%20L%20696%20214%20L%20661%20212%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20188%20L%20792%20195%20L%20786%20195%20L%20782%20190%20L%20783%20185%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20893%20155%20L%20864%20139%20L%20836%20130%20L%20813%20127%20L%20796%20127%20L%20774%20130%20L%20749%20138%20L%20735%20145%20L%20720%20155%20L%20702%20172%20L%20687%20173%20L%20700%20174%20L%20694%20181%20L%20674%20184%20L%20670%20180%20L%20665%20183%20L%20657%20184%20L%20639%20196%20L%20641%20198%20L%20647%20198%20L%20648%20194%20L%20654%20193%20L%20686%20204%20L%20724%20208%20L%20740%20213%20L%20751%20222%20L%20750%20223%20L%20693%20214%20L%20671%20212%20L%20639%20212%20L%20636%20211%20L%20634%20207%20L%20629%20207%20L%20626%20210%20L%20617%20210%20L%20614%20208%20L%20604%20207%20L%20594%20213%20L%20582%20216%20L%20580%20214%20L%20581%20210%20L%20576%20208%20L%20586%20200%20L%20565%20218%20L%20563%20222%20L%20618%20239%20L%20671%20251%20L%20682%20256%20L%20689%20261%20L%20702%20276%20L%20706%20288%20L%20704%20301%20L%20700%20308%20L%20683%20322%20L%20630%20348%20L%20585%20379%20L%20563%20398%20L%20523%20441%20L%20497%20475%20L%20459%20536%20L%20443%20567%20L%20431%20595%20L%20411%20626%20L%20387%20670%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20368%20706%20L%20333%20752%20L%20290%20818%20L%20281%20837%20L%20282%20844%20L%20300%20839%20L%20324%20825%20L%20326%20826%20L%20285%20884%20L%20242%20954%20L%20207%201020%20L%20192%201060%20L%20192%201071%20L%20196%201071%20L%20211%201062%20L%20240%201039%20L%20303%20983%20L%20305%20984%20L%20277%201023%20L%20242%201078%20L%20228%201110%20L%20227%201119%20L%20231%201130%20L%20239%201134%20L%20255%201134%20L%20274%201128%20L%20280%201134%20L%20285%201136%20L%20310%201137%20L%20339%201130%20L%20374%201112%20L%20394%201097%20L%20421%201071%20L%20445%201041%20L%20463%201013%20L%20488%20994%20L%20595%20900%20L%20614%20920%20L%20639%20951%20L%20639%20958%20L%20636%20961%20L%20619%20963%20L%20613%20966%20L%20607%20972%20L%20603%20980%20L%20602%20989%20L%20603%20995%20L%20608%201003%20L%20610%20994%20L%20620%20985%20L%20623%20986%20L%20631%20983%20L%20647%20981%20L%20665%20984%20L%20676%20989%20L%20689%201006%20L%20695%201007%20L%20697%201009%20L%20698%201023%20L%20694%201035%20L%20699%201032%20L%20709%201020%20L%20712%201010%20L%20710%20997%20L%20711%20992%20L%20719%201002%20L%20725%201005%20L%20727%201004%20L%20730%201008%20L%20731%201022%20L%20728%201032%20L%20739%201021%20L%20743%201010%20L%20743%201003%20L%20748%20993%20L%20748%20981%20L%20746%20974%20L%20739%20963%20L%20741%20961%20L%20755%20960%20L%20770%20964%20L%20779%20970%20L%20789%20983%20L%20799%20986%20L%20802%20992%20L%20802%201005%20L%20799%201012%20L%20808%201006%20L%20815%20993%20L%20813%20964%20L%20817%20968%20L%20825%20970%20L%20829%20978%20L%20831%20992%20L%20837%20981%20L%20837%20966%20L%20832%20952%20L%20822%20941%20L%20813%20936%20L%20803%20934%20L%20776%20936%20L%20763%20932%20L%20723%20890%20L%20702%20865%20L%20702%20863%20L%20717%20841%20L%20719%20842%20L%20708%20862%20L%20742%20840%20L%20782%20808%20L%20818%20773%20L%20851%20733%20L%20852%20736%20L%20846%20754%20L%20850%20752%20L%20881%20720%20L%20905%20686%20L%20932%20635%20L%20950%20590%20L%20969%20529%20L%20977%20494%20L%20982%20454%20L%20980%20419%20L%20975%20396%20L%20967%20377%20L%20968%20374%20L%20975%20382%20L%20976%20334%20L%20969%20284%20L%20959%20248%20L%20941%20209%20L%20921%20181%20Z%20M%20624%20888%20L%20666%20870%20L%20729%20934%20L%20729%20941%20L%20724%20944%20L%20716%20944%20L%20707%20951%20L%20683%20952%20L%20673%20948%20L%20659%20933%20Z%20M%20563%20618%20L%20569%20614%20L%20579%20621%20L%20576%20626%20L%20572%20627%20L%20568%20623%20L%20565%20623%20Z%20M%20575%20603%20L%20578%20603%20L%20588%20613%20L%20588%20619%20L%20585%20621%20L%20582%20620%20L%20575%20613%20L%20576%20612%20L%20573%20605%20Z%20M%20748%20398%20L%20752%20412%20L%20752%20432%20L%20748%20444%20L%20732%20470%20L%20716%20484%20L%20698%20493%20L%20685%20494%20L%20682%20491%20L%20707%20445%20L%20685%20473%20L%20667%20492%20L%20647%20508%20L%20632%20517%20L%20619%20519%20L%20615%20517%20L%20615%20513%20L%20643%20470%20L%20603%20514%20L%20589%20526%20L%20569%20538%20L%20558%20540%20L%20554%20539%20L%20552%20535%20L%20577%20498%20L%20549%20528%20L%20526%20546%20L%20508%20554%20L%20498%20555%20L%20495%20552%20L%20501%20541%20L%20482%20555%20L%20471%20558%20L%20464%20558%20L%20461%20560%20L%20460%20559%20L%20461%20555%20L%20469%20547%20L%20473%20537%20L%20491%20512%20L%20511%20497%20L%20582%20434%20L%20617%20408%20L%20632%20399%20L%20659%20386%20L%20677%20380%20L%20695%20377%20L%20712%20377%20L%20725%20380%20L%20739%20388%20Z%20M%20625%20216%20L%20683%20216%20L%20737%20222%20L%20742%20224%20L%20733%20226%20L%20721%20225%20L%20713%20230%20L%20711%20228%20L%20706%20228%20L%20694%20237%20L%20690%20233%20L%20690%20225%20L%20679%20228%20L%20674%20232%20L%20668%20232%20L%20659%20229%20L%20649%20222%20L%20632%20219%20L%20632%20217%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20187%20L%20795%20193%20L%20792%20195%20L%20786%20195%20L%20782%20190%20L%20783%20185%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20607%20972%20L%20603%20981%20L%20603%20995%20L%20608%201003%20L%20610%20994%20L%20616%20987%20L%20620%20985%20L%20627%20985%20L%20635%20982%20L%20655%20981%20L%20676%20989%20L%20683%20996%20L%20689%201006%20L%20695%201007%20L%20697%201009%20L%20698%201024%20L%20694%201034%20L%20699%201032%20L%20706%201025%20L%20712%201010%20L%20710%20994%20L%20709%20997%20L%20706%20994%20L%20700%20994%20L%20694%20997%20L%20672%20976%20L%20658%20974%20L%20647%20967%20L%20642%20974%20L%20638%20974%20L%20636%20972%20L%20636%20961%20L%20629%20963%20L%20631%20972%20L%20625%20977%20L%20620%20977%20L%20613%20967%20L%20614%20966%20Z%20M%20686%20966%20L%20704%20980%20L%20719%201002%20L%20727%201004%20L%20730%201007%20L%20731%201023%20L%20728%201032%20L%20731%201030%20L%20741%201017%20L%20743%201003%20L%20748%20992%20L%20748%20981%20L%20742%20967%20L%20740%20965%20L%20742%20968%20L%20735%20972%20L%20726%20965%20L%20705%20960%20L%20724%20968%20L%20740%20983%20L%20742%20987%20L%20740%20991%20L%20732%20991%20L%20728%20996%20L%20726%20996%20L%20721%20992%20L%20713%20979%20Z%20M%20784%20943%20L%20784%20946%20L%20798%20950%20L%20819%20969%20L%20825%20970%20L%20830%20981%20L%20831%20992%20L%20837%20981%20L%20837%20966%20L%20829%20948%20L%20825%20944%20L%20830%20950%20L%20821%20957%20L%20813%20951%20L%20794%20942%20Z%20M%20659%20933%20L%20662%20937%20L%20658%20942%20L%20658%20945%20L%20663%20947%20L%20666%20945%20L%20667%20946%20L%20668%20944%20L%20670%20946%20Z%20M%20643%20913%20L%20647%20918%20L%20644%20921%20L%20642%20928%20L%20629%20938%20L%20627%20936%20L%20636%20947%20L%20634%20945%20L%20641%20933%20L%20651%20923%20L%20657%20930%20Z%20M%20936%20310%20L%20934%20309%20L%20945%20338%20L%20949%20368%20L%20949%20381%20L%20947%20383%20L%20935%20367%20L%20914%20348%20L%20929%20377%20L%20934%20397%20L%20936%20414%20L%20935%20440%20L%20933%20442%20L%20930%20440%20L%20924%20419%20L%20912%20395%20L%20909%20393%20L%20910%20410%20L%20907%20433%20L%20899%20458%20L%20895%20462%20L%20893%20460%20L%20892%20445%20L%20888%20427%20L%20873%20390%20L%20873%20416%20L%20871%20430%20L%20866%20448%20L%20862%20454%20L%20859%20452%20L%20853%20433%20L%20842%20410%20L%20823%20382%20L%20807%20365%20L%20805%20366%20L%20811%20394%20L%20812%20416%20L%20810%20425%20L%20806%20428%20L%20801%20423%20L%20790%20403%20L%20772%20383%20L%20759%20372%20L%20733%20357%20L%20724%20355%20L%20705%20346%20L%20693%20344%20L%20669%20344%20L%20646%20349%20L%20636%20353%20L%20662%20349%20L%20677%20349%20L%20699%20353%20L%20708%20356%20L%20725%20366%20L%20736%20376%20L%20745%20389%20L%20753%20415%20L%20752%20442%20L%20743%20476%20L%20723%20520%20L%20693%20569%20L%20646%20631%20L%20605%20676%20L%20568%20709%20L%20565%20710%20L%20562%20706%20L%20562%20692%20L%20567%20665%20L%20578%20636%20L%20602%20619%20L%20622%20602%20L%20645%20578%20L%20670%20546%20L%20630%20588%20L%20603%20610%20L%20574%20628%20L%20560%20633%20L%20556%20633%20L%20555%20631%20L%20563%20617%20L%20596%20572%20L%20647%20509%20L%20636%20515%20L%20562%20609%20L%20541%20630%20L%20520%20645%20L%20502%20653%20L%20490%20653%20L%20536%20590%20L%20513%20617%20L%20478%20651%20L%20454%20665%20L%20440%20669%20L%20436%20667%20L%20488%20596%20L%20459%20630%20L%20436%20653%20L%20411%20671%20L%20392%20678%20L%20372%20701%20L%20319%20772%20L%20289%20820%20L%20281%20838%20L%20282%20844%20L%20295%20841%20L%20324%20825%20L%20326%20826%20L%20289%20878%20L%20242%20954%20L%20209%201016%20L%20193%201056%20L%20191%201069%20L%20192%201071%20L%20196%201071%20L%20219%201056%20L%20303%20983%20L%20305%20984%20L%20255%201057%20L%20234%201094%20L%20228%201111%20L%20228%201124%20L%20234%201132%20L%20240%201134%20L%20254%201134%20L%20275%201128%20L%20279%201133%20L%20291%201137%20L%20316%201136%20L%20344%201128%20L%20364%201118%20L%20389%201101%20L%20403%201089%20L%20427%201064%20L%20447%201038%20L%20463%201013%20L%20489%20993%20L%20594%20901%20L%20595%20899%20L%20593%20894%20L%20588%20891%20L%20567%20868%20L%20569%20865%20L%20576%20867%20L%20586%20873%20L%20589%20867%20L%20607%20867%20L%20619%20880%20L%20622%20887%20L%20626%20891%20L%20624%20889%20L%20626%20886%20L%20666%20870%20L%20705%20909%20L%20710%20905%20L%20713%20905%20L%20716%20908%20L%20716%20911%20L%20712%20916%20L%20728%20932%20L%20730%20939%20L%20727%20943%20L%20717%20944%20L%20710%20949%20L%20712%20948%20L%20725%20951%20L%20739%20963%20L%20740%20961%20L%20756%20960%20L%20774%20966%20L%20784%20975%20L%20789%20983%20L%20800%20987%20L%20802%20991%20L%20802%201005%20L%20799%201012%20L%20806%201008%20L%20811%201002%20L%20815%20993%20L%20813%20968%20L%20812%20973%20L%20807%20971%20L%20796%20976%20L%20791%20973%20L%20788%20967%20L%20779%20958%20L%20767%20952%20L%20762%20941%20L%20756%20940%20L%20751%20932%20L%20749%20931%20L%20743%20936%20L%20738%20936%20L%20736%20933%20L%20741%20925%20L%20739%20915%20L%20742%20912%20L%20745%20914%20L%20702%20865%20L%20702%20863%20L%20717%20841%20L%20719%20842%20L%20709%20861%20L%20746%20837%20L%20783%20807%20L%20826%20764%20L%20851%20732%20L%20853%20734%20L%20846%20754%20L%20851%20751%20L%20874%20728%20L%20890%20708%20L%20908%20681%20L%20938%20621%20L%20960%20560%20L%20971%20521%20L%20979%20481%20L%20982%20444%20L%20977%20405%20L%20970%20382%20L%20966%20375%20L%20968%20374%20L%20974%20381%20L%20975%20368%20L%20974%20372%20L%20968%20364%20L%20966%20354%20L%20957%20337%20Z%20M%20247%201077%20L%20251%201085%20L%20246%201088%20L%20245%201091%20L%20240%201092%20L%20239%201085%20L%20241%201081%20Z%20M%20370%20935%20L%20371%20937%20L%20326%20997%20L%20301%201034%20L%20289%201044%20L%20286%201042%20L%20283%201045%20L%20277%201045%20L%20273%201043%20L%20264%201052%20L%20261%201049%20L%20270%201037%20L%20271%201038%20L%20281%201027%20L%20293%201010%20L%20326%20971%20Z%20M%20726%20921%20L%20728%20921%20L%20731%20926%20L%20729%20933%20L%20722%20926%20Z%20M%20729%20897%20L%20734%20902%20L%20732%20906%20L%20726%20902%20L%20726%20899%20Z%20M%20715%20882%20L%20720%20886%20L%20718%20890%20L%20711%20887%20Z%20M%20605%20762%20L%20608%20761%20L%20607%20760%20L%20609%20757%20L%20610%20759%20L%20617%20761%20L%20620%20764%20L%20611%20770%20L%20607%20770%20L%20606%20765%20L%20608%20765%20Z%20M%20611%20754%20L%20617%20750%20L%20620%20751%20L%20621%20749%20L%20625%20749%20L%20628%20752%20L%20628%20756%20L%20623%20761%20Z%20M%20549%20710%20L%20551%20713%20L%20551%20722%20L%20506%20774%20L%20403%20878%20L%20309%20963%20L%20307%20961%20L%20313%20953%20L%20322%20945%20L%20340%20922%20L%20375%20885%20Z%20M%20695%20704%20L%20696%20708%20L%20698%20709%20L%20695%20710%20L%20695%20713%20L%20692%20715%20L%20688%20710%20Z%20M%20704%20694%20L%20706%20695%20L%20706%20698%20L%20710%20698%20L%20713%20703%20L%20708%20708%20L%20707%20714%20L%20705%20716%20L%20701%20716%20L%20697%20708%20L%20699%20706%20L%20697%20705%20L%20701%20701%20L%20704%20705%20L%20706%20704%20L%20706%20700%20L%20702%20697%20Z%20M%20552%20678%20L%20554%20681%20L%20552%20701%20L%20465%20787%20L%20361%20883%20L%20316%20927%20L%20253%20993%20L%20211%201042%20L%20211%201036%20L%20222%201011%20L%20261%20942%20L%20292%20898%20L%20338%20843%20L%20374%20807%20L%20387%20807%20L%20403%20801%20L%20419%20792%20L%20456%20766%20L%20500%20728%20Z%20M%20704%20654%20L%20706%20658%20L%20711%20658%20L%20714%20661%20L%20715%20673%20L%20713%20676%20L%20705%20676%20L%20702%20674%20L%20704%20677%20L%20704%20681%20L%20702%20682%20L%20704%20685%20L%20700%20686%20L%20694%20678%20L%20691%20678%20L%20688%20674%20L%20692%20670%20L%20692%20664%20L%20694%20661%20Z%20M%20565%20642%20L%20558%20663%20L%20545%20677%20L%20494%20726%20L%20443%20767%20L%20401%20792%20L%20392%20795%20L%20387%20794%20L%20405%20766%20L%20455%20707%20L%20388%20767%20L%20355%20794%20L%20304%20827%20L%20299%20826%20L%20302%20817%20L%20316%20795%20L%20360%20739%20L%20398%20700%20L%20425%20677%20L%20428%20679%20L%20443%20679%20L%20461%20673%20L%20476%20664%20L%20493%20666%20L%20517%20657%20L%20542%20641%20L%20546%20643%20Z%20M%20563%20221%20L%20572%20225%20L%20571%20223%20L%20574%20220%20L%20585%20217%20L%20596%20218%20L%20607%20215%20L%20653%20214%20L%20706%20218%20L%20749%20224%20L%20749%20226%20L%20735%20233%20L%20732%20238%20L%20732%20249%20L%20693%20242%20L%20620%20239%20L%20672%20251%20L%20688%20260%20L%20701%20274%20L%20706%20287%20L%20704%20301%20L%20707%20297%20L%20711%20300%20L%20713%20309%20L%20720%20324%20L%20723%20327%20L%20724%20308%20L%20726%20304%20L%20735%20318%20L%20757%20341%20L%20753%20319%20L%20754%20313%20L%20760%20317%20L%20786%20345%20L%20784%20324%20L%20775%20300%20L%20779%20300%20L%20800%20317%20L%20800%20312%20L%20787%20283%20L%20772%20264%20L%20778%20263%20L%20800%20271%20L%20803%20270%20L%20770%20236%20L%20772%20234%20L%20797%20234%20L%20807%20230%20L%20801%20230%20L%20800%20228%20L%20811%20218%20L%20818%20203%20L%20818%20193%20L%20812%20179%20L%20798%20168%20L%20784%20166%20L%20760%20173%20L%20747%20173%20L%20712%20163%20L%20718%20157%20L%20703%20171%20L%20711%20173%20L%20720%20178%20L%20723%20182%20L%20720%20186%20L%20695%20190%20L%20680%20190%20L%20670%20193%20L%20656%20193%20L%20687%20204%20L%20729%20209%20L%20742%20214%20L%20753%20223%20L%20752%20224%20L%20729%20219%20L%20669%20212%20L%20610%20213%20L%20576%20218%20L%20565%20221%20L%20565%20219%20Z%20M%20771%20189%20L%20772%20201%20L%20775%20207%20L%20780%20212%20L%20787%20215%20L%20796%20216%20L%20796%20218%20L%20784%20219%20L%20776%20215%20L%20771%20210%20L%20768%20204%20L%20768%20194%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20187%20L%20796%20192%20L%20790%20196%20L%20786%20195%20L%20782%20191%20L%20782%20186%20Z'/%3e%3c/g%3e%3c/svg%3e`;function me({className:e}){return(0,L.jsx)(`span`,{className:`block overflow-hidden rounded-[20.7%] bg-accent ${e??``}`,children:(0,L.jsx)(`img`,{src:pe,alt:``,"aria-hidden":`true`,className:`h-full w-full`})})}function he({open:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2.5`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-3.5 w-3.5 shrink-0 transition-transform ${e?`rotate-90`:``}`,children:(0,L.jsx)(`path`,{d:`m9 18 6-6-6-6`})})}function ge({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M3 6h.01`}),(0,L.jsx)(`path`,{d:`M3 12h.01`}),(0,L.jsx)(`path`,{d:`M3 18h.01`}),(0,L.jsx)(`path`,{d:`M8 6h13`}),(0,L.jsx)(`path`,{d:`M8 12h13`}),(0,L.jsx)(`path`,{d:`M8 18h13`})]})}function _e({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`}),(0,L.jsx)(`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`}),(0,L.jsx)(`path`,{d:`M10 9H8`}),(0,L.jsx)(`path`,{d:`M16 13H8`}),(0,L.jsx)(`path`,{d:`M16 17H8`})]})}function ve({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`path`,{d:`m4 17 6-6-6-6`}),(0,L.jsx)(`path`,{d:`M12 19h8`})]})}function ye({repos:e,currentId:t,onSelect:n,onCloseProject:r,onOpenPicker:i,className:a=``}){let[o,s]=(0,v.useState)(!1),c=(0,v.useRef)(null),l=e.find(e=>e.id===t);return(0,v.useEffect)(()=>{if(!o)return;let e=e=>{e.key===`Escape`&&(s(!1),c.current?.focus())};return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[o]),(0,L.jsxs)(`div`,{className:`relative ${a}`,children:[(0,L.jsxs)(`button`,{ref:c,onClick:()=>s(e=>!e),"aria-haspopup":`menu`,"aria-expanded":o,title:l?.display_path??`Select a project`,className:`flex max-w-[9rem] items-center gap-1 rounded-sm bg-ink-700 py-0.5 pl-2 pr-1 text-ink-50`,children:[(0,L.jsx)(`span`,{className:`truncate`,children:l?.name??`No project`}),(0,L.jsx)(he,{open:o})]}),o&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`div`,{className:`fixed inset-0 z-40`,onClick:()=>s(!1)}),(0,L.jsxs)(`div`,{role:`menu`,className:`absolute left-0 z-50 mt-1 max-h-[70vh] w-56 max-w-[80vw] overflow-y-auto rounded-md border border-ink-700 bg-ink-900 py-1 shadow-lg`,children:[e.length===0&&(0,L.jsx)(`p`,{className:`px-3 py-1.5 text-ink-400`,children:`No projects open.`}),e.map(e=>(0,L.jsxs)(`div`,{className:`flex items-center ${e.id===t?`bg-ink-700 text-ink-50`:`text-ink-200`}`,children:[(0,L.jsx)(`button`,{role:`menuitem`,onClick:()=>{n(e.id),s(!1)},title:e.display_path,className:`min-w-0 flex-1 truncate py-1.5 pl-3 pr-1 text-left hover:text-accent`,children:e.name}),(0,L.jsx)(`button`,{onClick:()=>r(e.id),"aria-label":`close ${e.name}`,title:`Close project`,className:`mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed`,children:(0,L.jsx)(se,{className:`h-3.5 w-3.5`})})]},e.id)),(0,L.jsx)(`div`,{className:`my-1 border-t border-ink-800`}),(0,L.jsxs)(`button`,{role:`menuitem`,onClick:()=>{i(),s(!1)},className:`flex w-full items-center gap-1 px-3 py-1.5 text-left text-ink-400 hover:text-ink-200`,children:[(0,L.jsx)(ce,{className:`h-3.5 w-3.5`}),`open`]})]})]})]})}function be(){let[e,t]=(0,v.useState)(!1),n=(0,v.useRef)(!1);return{reload:(0,v.useCallback)(async()=>{if(!n.current){n.current=!0,t(!0);try{P.success(await k.reloadConfig())}catch(e){P.error(e instanceof Error?e.message:`could not reload the config`)}finally{n.current=!1,t(!1)}}},[]),pending:e}}function xe(e,t=14){let n=[...e];return n.length<=t?e:`${n.slice(0,Math.max(0,t-1)).join(``)}…`}function Se({repos:e,repo:t,onSelectRepo:n,onCloseRepo:r,onOpenPicker:i,cloning:a,accent:o,next:s,cycle:c,draggingRepo:l,dragOverRepo:u,onRepoDragStart:d,onRepoDragMove:f,onRepoDragEnd:p}){let{reload:m,pending:h}=be();return(0,L.jsxs)(`header`,{className:`flex items-center gap-2 border-b border-ink-700 bg-ink-900 px-[12.8px] py-[8.8px]`,children:[(0,L.jsx)(me,{className:`h-[22px] w-[22px] shrink-0`}),(0,L.jsx)(`span`,{className:`text-[16px] font-medium tracking-[0.04em] text-ink-50`,children:`nightcrow`}),(0,L.jsx)(`span`,{className:`hidden font-sans text-[10px] uppercase tracking-[0.18em] text-ink-400 sm:inline`,children:`web viewer`}),(0,L.jsx)(ye,{className:`md:hidden`,repos:e,currentId:t,onSelect:n,onCloseProject:r,onOpenPicker:i}),(0,L.jsx)(`nav`,{className:`-my-[8.8px] hidden items-stretch self-stretch overflow-x-auto pl-1 md:flex`,children:e.map(i=>(0,L.jsxs)(`div`,{"data-repo-id":i.id,onPointerDown:e=>d(e,i.id),onPointerMove:f,onPointerUp:p,onPointerCancel:p,onLostPointerCapture:p,className:`flex items-center border-r border-ink-700 whitespace-nowrap ${e.length>1?`touch-none`:``} ${l===i.id?`opacity-60`:``} ${u===i.id?`bg-ink-800 ring-1 ring-inset ring-accent`:``} ${i.id===t?`bg-ink-950 text-ink-50 shadow-[inset_0_2px_0_0_var(--color-accent)]`:`text-ink-400 hover:bg-ink-850 hover:text-ink-200`}`,title:i.display_path,children:[(0,L.jsx)(`button`,{onClick:()=>{n(i.id)},"aria-label":i.name,className:`self-stretch pl-3 pr-1`,children:xe(i.name)}),(0,L.jsx)(`button`,{onClick:e=>{e.stopPropagation(),r(i.id)},"data-tab-close":!0,title:`Close project`,"aria-label":`close ${i.name}`,className:`mr-1 flex h-5 w-5 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-removed`,children:(0,L.jsx)(se,{className:`h-3.5 w-3.5`})})]},i.id))}),(0,L.jsxs)(`button`,{onClick:i,title:`Open a project`,className:`hidden shrink-0 items-center gap-1 rounded-sm px-2 py-0.5 text-ink-400 hover:text-ink-200 md:inline-flex`,children:[(0,L.jsx)(ce,{className:`h-3.5 w-3.5`}),`open`]}),a&&(0,L.jsxs)(`span`,{role:`status`,title:`A clone is running on the server`,className:`flex shrink-0 items-center gap-1.5 px-2 py-0.5 text-ink-400`,children:[(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`h-1.5 w-1.5 animate-pulse rounded-full bg-accent`}),`Cloning…`]}),(0,L.jsx)(`button`,{onClick:c,title:`Accent: ${o.name} (click for ${s.name})`,"aria-label":`accent colour: ${o.name}, click for ${s.name}`,className:`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-sm`,children:(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`h-3 w-3 rounded-full bg-accent ring-1 ring-ink-600`})}),(0,L.jsx)(`button`,{onClick:m,disabled:h,title:`Reload config.toml on the server (does not reload this page)`,"aria-label":`reload the server config`,className:`ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200 disabled:cursor-progress disabled:text-ink-500 disabled:hover:bg-transparent`,children:(0,L.jsx)(de,{className:`h-3.5 w-3.5 ${h?`animate-spin`:``}`})}),(0,L.jsx)(`a`,{href:`/logout`,title:`Sign out`,"aria-label":`sign out`,className:`ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200`,children:(0,L.jsx)(ue,{className:`h-3.5 w-3.5`})})]})}function Ce(){return(0,L.jsx)(`div`,{className:`flex h-full items-center justify-center p-6`,children:(0,L.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-ink-400`,children:[(0,L.jsx)(me,{className:`h-12 w-12 animate-pulse`}),(0,L.jsx)(`span`,{className:`text-[0.72rem] tracking-[0.18em] uppercase`,children:`Loading…`})]})})}function we({onSuccess:e}){let[t,n]=(0,v.useState)(``),[r,i]=(0,v.useState)(null),[a,o]=(0,v.useState)(!1);return(0,L.jsx)(`div`,{className:`flex h-full items-center justify-center p-6`,children:(0,L.jsxs)(`form`,{onSubmit:async n=>{n.preventDefault(),o(!0),i(null);try{await k.login(t),e()}catch(e){i(e instanceof Error?e.message:`login failed`)}finally{o(!1)}},className:`w-[17rem] max-w-[86vw]`,children:[(0,L.jsx)(me,{className:`mx-auto mb-3 block h-10 w-10`}),(0,L.jsx)(`h1`,{className:`text-center text-lg font-medium tracking-wide text-ink-50`,children:`nightcrow`}),(0,L.jsx)(`p`,{className:`mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase`,children:`web viewer`}),r&&(0,L.jsx)(`p`,{className:`mb-2.5 text-center text-removed`,children:r}),(0,L.jsx)(`input`,{type:`password`,autoFocus:!0,value:t,onChange:e=>n(e.target.value),placeholder:`password`,className:`mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15`}),(0,L.jsx)(`button`,{type:`submit`,disabled:a,className:`w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50`,children:a?`Signing in…`:`Sign in`})]})})}var Te=`nightcrow.sidebarWidth`,Ee=.5;function De(e){return Math.min(Math.max(Math.round(e),280),720)}function Oe(e){let t=720;try{t=Math.min(t,Math.round(window.innerWidth*Ee))}catch{}return Math.min(Math.max(Math.round(e),280),Math.max(t,280))}function ke(){try{let e=Number(localStorage.getItem(Te));return Number.isFinite(e)&&e>0?De(e):460}catch{return 460}}function Ae(e){try{localStorage.setItem(Te,String(e))}catch{}}function je(){let[e,t]=(0,v.useState)(ke);return{width:e,resize:(0,v.useCallback)(e=>{let n=Oe(e);t(n),Ae(n)},[]),commit:(0,v.useCallback)(e=>{let n=Oe(e);t(n),Ae(n),k.setSidebarWidth(n).catch(()=>{})},[]),reset:(0,v.useCallback)(()=>{let e=De(460);t(e),Ae(e),k.setSidebarWidth(e).catch(()=>{})},[]),adopt:(0,v.useCallback)(e=>{t(t=>{let n=De(e);return n===t?t:(Ae(n),n)})},[])}}function Me(){let e=new Map;return{start(t){let n=(e.get(t)??0)+1;return e.set(t,n),n},isCurrent(t,n){return e.get(t)===n}}}var Ne={children:{},expanded:new Set};function Pe(e,t,n){return{...e,children:{...e.children,[t]:n}}}function Fe(e,t){let n=new Set(e);return n.delete(t)||n.add(t),n}function Ie(e,t){return{...e,expanded:Fe(e.expanded,t)}}function Le(e,t){let n=new Set(e.expanded);return t.forEach(e=>n.add(e)),{...e,expanded:n}}function Re(e){let t=[],n=``;for(let r of e.split(`/`))n=n?`${n}/${r}`:r,t.push(n);return t}var ze=180,Be={items:[],truncated:!1};function Ve({repo:e,authed:t,tab:n,filter:r,filterOpen:i,handle:a}){let[o,s]=(0,v.useState)(Ne),[c,l]=(0,v.useState)(Be),[u,d]=(0,v.useState)(!1),[f]=(0,v.useState)(Me);(0,v.useEffect)(()=>{if(!e||!t||n!==`tree`||!i||!r){l(Be),d(!1);return}d(!0);let o=!0,s=setTimeout(()=>{k.treeSearch(e,r).then(e=>{o&&l({items:e.matches,truncated:e.truncated})}).catch(e=>{o&&a(e)}).finally(()=>{o&&d(!1)})},ze);return()=>{o=!1,clearTimeout(s)}},[e,t,n,r,i,a]);let p=(0,v.useCallback)((t,n)=>{if(!e)return;let r=f.start(t);k.tree(e,t).then(e=>{f.isCurrent(t,r)&&s(n=>Pe(n,t,e.entries))}).catch(e=>{if(!f.isCurrent(t,r)||n?.restoring)return x(e)?a(e):void 0;a(e)})},[e,a,f]);(0,v.useEffect)(()=>{!e||!t||n!==`tree`||p(``)},[e,t,n,p]);let m=(0,v.useCallback)(e=>{let t=!o.expanded.has(e);s(t=>Ie(t,e)),t&&!(e in o.children)&&p(e)},[o,p]),h=(0,v.useCallback)(e=>{s(t=>({...t,expanded:new Set(e)})),e.forEach(e=>{e in o.children||p(e,{restoring:!0})})},[o,p]),g=(0,v.useCallback)(e=>{let t=Re(e);s(e=>Le(e,t)),t.forEach(e=>{e in o.children||p(e)})},[o,p]);return{treeChildren:o.children,treeExpanded:o.expanded,treeMatches:c.items,treeTruncated:c.truncated,treeSearchLoading:u,loadTreeChildren:p,toggleTreeDir:m,revealTreeDir:g,seedTreeExpanded:h}}function He(e,t){let n=[],r=(i,a)=>{for(let o of e[i]??[]){let e=i?`${i}/${o.name}`:o.name;n.push({path:e,name:o.name,is_dir:o.is_dir,depth:a}),o.is_dir&&t.has(e)&&r(e,a+1)}};return r(``,0),n}function Ue({path:e,from:t,className:n}){return(0,L.jsx)(`span`,{className:`whitespace-nowrap ${n??``}`,title:t?`${t} → ${e}`:e,children:t?`${t} → ${e}`:e})}var We=1e3;function Ge(e,t){return e===void 0||e<=0?0:e-t}function Ke(e,t,n){let r=Ge(t,n);return e===null||Math.abs(r-e)>=1e3?r:e}function qe(e,t,n){if(e===void 0)return`cool`;let r=Math.max(0,t-e);return r>=n?`cool`:r<5e3?`fresh`:`warm`}function Je(e,t,n){return e.some(e=>qe(e,t,n)!==`cool`)}var Ye={fresh:`text-accent font-bold`,warm:`text-accent`,cool:``};function Xe(e,t,n){let[r,i]=(0,v.useState)(()=>Date.now()+n);return(0,v.useEffect)(()=>{if(t<=0||!e)return;let r=e.map(e=>e.mtime),a=Date.now()+n;if(i(a),!Je(r,a,t))return;let o=setInterval(()=>{let e=Date.now()+n;i(e),Je(r,e,t)||clearInterval(o)},We);return()=>clearInterval(o)},[e,t,n]),r}function Ze(e){let t=Math.max(0,Math.floor(Date.now()/1e3-e));return t<60?`${t}s`:t<3600?`${Math.floor(t/60)}m`:t<86400?`${Math.floor(t/3600)}h`:t<86400*30?`${Math.floor(t/86400)}d`:t<86400*365?`${Math.floor(t/(86400*30))}mo`:`${Math.floor(t/(86400*365))}y`}function Qe(e){return e===`+`?`bg-added/10`:e===`-`?`bg-removed/10`:``}function $e(e){return e===`?`?`text-ink-400`:e===`D`?`text-removed`:e===`A`?`text-added`:`text-accent`}function et({status:e,files:t,now:n,hotWindowMs:r,openDiff:i}){return e===null?(0,L.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`Loading…`}):(0,L.jsxs)(L.Fragment,{children:[t.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{onClick:()=>i(e.path),className:`flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,L.jsxs)(`span`,{className:`shrink-0`,children:[(0,L.jsx)(`span`,{className:$e(e.index),children:e.index===` `?` `:e.index}),(0,L.jsx)(`span`,{className:$e(e.worktree),children:e.worktree===` `?` `:e.worktree})]}),(0,L.jsx)(Ue,{path:e.path,from:e.old_path,className:Ye[qe(e.mtime,n,r)]})]})},e.path)),e.truncated&&(0,L.jsxs)(`li`,{className:`px-3 py-1 text-accent`,children:[`Showing the first `,e.files.length,` changed files.`]})]})}function tt({visibleCommits:e,commits:t,aheadOids:n,commitDrillDown:r,visibleCommitFiles:i,logDone:a,logStalled:o,logPagingPaused:s,setLogStalled:c,logSentinelRef:l,openCommitFiles:u,openCommit:d,openCommitFileDiff:f,setCommitDrillDown:p,setPaneEmpty:m,bumpPaneRequest:h}){return(0,L.jsxs)(L.Fragment,{children:[!r&&e.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{onClick:()=>void u(e),title:`${e.author} · ${e.summary}`,className:`flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,L.jsx)(`span`,{className:`w-2 shrink-0 text-added`,children:n.has(e.oid)?`↑`:``}),(0,L.jsx)(`span`,{className:`shrink-0 text-accent`,children:e.short_id}),(0,L.jsx)(`span`,{className:`w-10 shrink-0 text-right text-ink-400`,children:Ze(e.time)}),(0,L.jsx)(`span`,{className:`max-w-[6rem] shrink-0 truncate text-ink-400`,children:e.author}),(0,L.jsx)(`span`,{className:`whitespace-nowrap`,children:e.summary})]})},e.oid)),!r&&!a&&!o&&!s&&(0,L.jsx)(`li`,{ref:l,className:`px-3 py-1 text-ink-400`,"aria-hidden":`true`,children:`loading…`}),!r&&!a&&!o&&s&&(0,L.jsxs)(`li`,{className:`px-3 py-1 text-ink-400`,children:[`filtering `,t.length,` loaded commits — clear the filter to load more`]}),!r&&o&&(0,L.jsx)(`li`,{className:`px-3 py-1`,children:(0,L.jsx)(`button`,{onClick:()=>c(!1),className:`text-ink-400 hover:text-accent`,children:`could not load more — retry`})}),r&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(`li`,{className:`sticky top-0 z-10 flex w-max min-w-full items-center gap-1 bg-ink-900 px-2 py-1 text-ink-400`,children:[(0,L.jsx)(`button`,{onClick:()=>{h(),p(null),m()},className:`rounded-sm px-1 hover:text-accent`,title:`Back to commit log`,children:`< log`}),(0,L.jsx)(`span`,{className:`text-ink-600`,children:`·`}),(0,L.jsx)(`span`,{className:`shrink-0 text-accent`,children:r.commit.short_id}),(0,L.jsx)(`button`,{onClick:()=>d(r.commit.oid),className:`rounded-sm px-1 hover:text-accent`,title:`Show the complete commit diff`,children:`all changes`})]}),i.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{onClick:()=>f(r.commit.oid,e.path),className:`flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,L.jsx)(`span`,{className:$e(e.index),children:e.index}),(0,L.jsx)(Ue,{path:e.path,from:e.old_path})]})},e.path)),r.files.length===0&&(0,L.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`No changed files.`}),r.files.length>0&&i.length===0&&(0,L.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`No matching files.`}),r.truncated&&(0,L.jsxs)(`li`,{className:`px-3 py-1 text-accent`,children:[`Showing the first `,r.files.length,` files.`]})]})]})}function nt({treeSearching:e,treeMatches:t,treeTruncated:n,treeSearchLoading:r,treeRows:i,treeExpanded:a,openFile:o,revealTreeDir:s,toggleTreeDir:c}){return e?(0,L.jsxs)(L.Fragment,{children:[t.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsx)(`button`,{onClick:()=>{e.is_dir?s(e.path):o(e.path)},title:e.path,className:`w-max min-w-full whitespace-nowrap px-3 py-0.5 text-left hover:bg-ink-850`,children:e.is_dir?(0,L.jsxs)(`span`,{className:`text-accent`,children:[e.path,`/`]}):e.path})},e.path)),t.length===0&&(0,L.jsx)(`li`,{className:`px-3 py-0.5 text-ink-400`,children:r?`searching…`:`no matches`}),n&&(0,L.jsxs)(`li`,{className:`px-3 py-0.5 text-ink-400`,children:[`showing the first `,t.length,` matches`]})]}):(0,L.jsx)(L.Fragment,{children:i.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{onClick:()=>e.is_dir?c(e.path):o(e.path),title:e.path,style:{paddingLeft:`${e.depth*.75+.5}rem`},className:`flex w-max min-w-full items-center gap-1 py-0.5 pr-3 text-left hover:bg-ink-850`,children:[e.is_dir?(0,L.jsx)(he,{open:a.has(e.path)}):(0,L.jsx)(`span`,{className:`h-3.5 w-3.5 shrink-0`}),(0,L.jsx)(`span`,{className:`whitespace-nowrap ${e.is_dir?`text-accent`:``}`,children:e.is_dir?`${e.name}/`:e.name})]})},e.path))})}function rt(e){let{tab:t,setTab:n,filter:r,setFilter:i,filterOpen:a,setFilterOpen:o,status:s,files:c,now:l,hotWindowMs:u,openDiff:d,openFile:f,openCommit:p,openCommitFileDiff:m,openCommitFiles:h,repo:g,authed:_,handle:y,sidebarRef:b,draggingSidebar:x,onSidebarDragStart:S,onSidebarDragMove:C,onSidebarDragEnd:w,onSidebarDragCancel:T,filesMax:E,bumpPaneRequest:D,commits:O,logDone:ee,logStalled:k,setLogStalled:te,commitDrillDown:A,setCommitDrillDown:j,resetLog:ne,logSentinelRef:re,visibleCommits:M,logPagingPaused:N,aheadOids:ie,visibleCommitFiles:ae,mobileView:oe,restoreTree:P,restoreKnown:F,onTreeExpanded:I,clearPane:se,touched:ce}=e,ue=Ve({repo:g,authed:_,tab:t,filter:r,filterOpen:a,handle:y}),de=t===`tree`&&a&&r!==``,fe=He(ue.treeChildren,ue.treeExpanded),{seedTreeExpanded:pe}=ue,me=(0,v.useRef)(!1);return(0,v.useEffect)(()=>{me.current||!F||ce||t===`tree`&&(me.current=!0,P.length!==0&&pe(P))},[t,F,ce,P,pe]),(0,L.jsxs)(`section`,{ref:b,className:`relative min-h-0 flex-col overflow-hidden ${oe===`files`?`flex`:`hidden md:flex`} ${E?`md:flex`:`border-ink-700 md:border-r`}`,children:[!E&&(0,L.jsx)(`div`,{role:`separator`,"aria-orientation":`vertical`,"aria-label":`Resize the file sidebar (double-click to reset)`,title:`Drag to resize · double-click to reset`,onPointerDown:S,onPointerMove:C,onPointerUp:w,onPointerCancel:T,onLostPointerCapture:w,className:`absolute -right-px top-0 z-20 hidden h-full w-1.5 cursor-col-resize touch-none md:block ${x?`bg-accent`:`hover:bg-accent`}`}),(0,L.jsxs)(`div`,{className:`flex shrink-0 items-stretch border-b border-ink-700 px-2`,children:[[`status`,`log`,`tree`].map(e=>(0,L.jsx)(`button`,{onClick:()=>{e!==t&&(D(),t===`log`&&(j(null),ne()),n(e),se())},"aria-current":e===t?`page`:void 0,className:`-mb-px border-b-2 px-2 py-1 ${e===t?`border-accent text-ink-50`:`border-transparent text-ink-400 hover:text-ink-200`}`,children:e},e)),(0,L.jsx)(`button`,{onClick:()=>{a&&i(``),o(e=>!e)},"aria-pressed":a,title:a?`Hide the filter`:`Filter the list`,"aria-label":a?`Hide the filter`:`Filter the list`,className:`my-1 ml-auto flex shrink-0 items-center rounded-sm px-1.5 hover:text-accent ${a?`text-ink-50`:`text-ink-400`}`,children:(0,L.jsx)(le,{})})]}),a&&(0,L.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:`filter…`,autoFocus:!0,className:`mx-2 mb-1 shrink-0 rounded-sm bg-ink-850 px-2 py-1 outline-none placeholder:text-ink-400 focus:ring-1 focus:ring-accent`}),(0,L.jsxs)(`ul`,{className:`min-h-0 flex-1 overflow-auto`,children:[t===`status`&&(0,L.jsx)(et,{status:s,files:c,now:l,hotWindowMs:u,openDiff:d}),t===`log`&&(0,L.jsx)(tt,{visibleCommits:M,commits:O,aheadOids:ie,commitDrillDown:A,visibleCommitFiles:ae,logDone:ee,logStalled:k,logPagingPaused:N,setLogStalled:te,logSentinelRef:re,openCommitFiles:h,openCommit:p,openCommitFileDiff:m,setCommitDrillDown:j,setPaneEmpty:se,bumpPaneRequest:D}),t===`tree`&&(0,L.jsx)(nt,{treeSearching:de,treeMatches:ue.treeMatches,treeTruncated:ue.treeTruncated,treeSearchLoading:ue.treeSearchLoading,treeRows:fe,treeExpanded:ue.treeExpanded,openFile:f,revealTreeDir:e=>{let t=new Set(ue.treeExpanded);Re(e).forEach(e=>t.add(e)),I([...t]),ue.revealTreeDir(e)},toggleTreeDir:e=>{I([...Fe(ue.treeExpanded,e)]),ue.toggleTreeDir(e)}})]})]})}function it(e){let t=[],n=[],r=[],i=()=>{let e=Math.max(n.length,r.length);for(let i=0;i{t(e=>e===`split`?`unified`:`split`)},[])}}var ot=[`.md`,`.markdown`],st=[`.html`,`.htm`];function ct(e){let t=e.toLowerCase();return ot.some(e=>t.endsWith(e))}function lt(e){let t=e.toLowerCase();return st.some(e=>t.endsWith(e))}function ut(e){return ct(e)||lt(e)}function dt(e){return e.map(e=>e.map(e=>e.t).join(``)).join(` -`)}var ft=3;function pt(e){let t=e<1?1:String(Math.floor(e)).length;return Math.max(t,ft)}function mt(e){let t=0;for(let n of e)for(let e of n.lines)t=Math.max(t,e.old_lineno??0,e.new_lineno??0);return pt(t)}function ht(e,t=0){if(new Set(e.hunks.map(t=>t.file_path??e.path)).size>1)return null;for(let n of e.hunks.slice(Math.max(0,t)))for(let e of n.lines)if(e.new_lineno!==void 0)return e.new_lineno;return null}function gt(e){return Math.max(0,e-1-2)}function _t(e,t){return t<=0?null:Math.min(e,t)}function vt(e,t){let n=0;return e.forEach((e,r)=>{e<=t&&(n=r)}),n}function yt(e){return e.kind===`empty`||!e.source?null:{want:e.kind===`diff`?`file`:`diff`,source:e.source}}function bt(e){return e.index!==`D`&&e.worktree!==`D`}function xt(e){return e.hunks.some(e=>e.lines.some(e=>e.old_lineno!==void 0||e.new_lineno!==void 0))}function St(e){return e.kind===`workdir`?`workdir:${e.path}`:`commit:${e.oid}:${e.path}`}function Ct({maximized:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:e?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`path`,{d:`M8 3v3a2 2 0 0 1-2 2H3`}),(0,L.jsx)(`path`,{d:`M21 8h-3a2 2 0 0 1-2-2V3`}),(0,L.jsx)(`path`,{d:`M3 16h3a2 2 0 0 1 2 2v3`}),(0,L.jsx)(`path`,{d:`M16 21v-3a2 2 0 0 1 2-2h3`})]}):(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`path`,{d:`M8 3H5a2 2 0 0 0-2 2v3`}),(0,L.jsx)(`path`,{d:`M21 8V5a2 2 0 0 0-2-2h-3`}),(0,L.jsx)(`path`,{d:`M3 16v3a2 2 0 0 0 2 2h3`}),(0,L.jsx)(`path`,{d:`M16 21h3a2 2 0 0 0 2-2v-3`})]})})}function wt(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`}),(0,L.jsx)(`path`,{d:`M12 3v18`})]})}function Tt(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`rect`,{width:`18`,height:`13`,x:`3`,y:`8`,rx:`2`}),(0,L.jsx)(`path`,{d:`M3 8V6a2 2 0 0 1 2-2h5v4`})]})}function Et(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`path`,{d:`M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z`}),(0,L.jsx)(`circle`,{cx:`12`,cy:`12`,r:`3`})]})}function Dt(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`rect`,{width:`20`,height:`14`,x:`2`,y:`5`,rx:`2`}),(0,L.jsx)(`path`,{d:`M6 9h.01`}),(0,L.jsx)(`path`,{d:`M10 9h.01`}),(0,L.jsx)(`path`,{d:`M14 9h.01`}),(0,L.jsx)(`path`,{d:`M18 9h.01`}),(0,L.jsx)(`path`,{d:`M8 13h8`})]})}function Ot({className:e=`h-4 w-4`}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,L.jsx)(`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`}),(0,L.jsx)(`path`,{d:`M12 17v4`}),(0,L.jsx)(`path`,{d:`M8 21h8`}),(0,L.jsx)(`path`,{d:`m9 13 6-6`}),(0,L.jsx)(`path`,{d:`M9 10v3h3`}),(0,L.jsx)(`path`,{d:`M15 10V7h-3`})]})}function kt(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,L.jsx)(`path`,{d:`M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z`}),(0,L.jsx)(`path`,{d:`M14 3v5h5`}),(0,L.jsx)(`path`,{d:`M9 13h6`}),(0,L.jsx)(`path`,{d:`M9 17h3`})]})}function At({nos:e,digits:t,tint:n=``}){return(0,L.jsx)(`span`,{className:`sticky left-0 shrink-0 select-none bg-ink-950`,children:(0,L.jsx)(`span`,{className:`flex gap-[1ch] px-[1ch] text-ink-400 ${n}`,children:e.map((e,n)=>(0,L.jsx)(`span`,{className:`text-right`,style:{width:`${t}ch`},children:e??``},n))})})}function jt({line:e}){return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`span`,{className:`text-ink-400 select-none`,children:e.kind}),e.spans.map((e,t)=>(0,L.jsx)(`span`,{style:{color:e.c},children:e.t},t))]})}function Mt({line:e,digits:t,side:n}){if(e===null)return(0,L.jsxs)(`div`,{className:`flex bg-ink-900/40`,children:[(0,L.jsx)(At,{nos:[void 0],digits:t,tint:`bg-ink-900/40`}),(0,L.jsx)(`span`,{className:`whitespace-pre pr-3`,children:` `})]});let r=Qe(e.kind);return(0,L.jsxs)(`div`,{className:`flex ${r}`,children:[(0,L.jsx)(At,{nos:[n===`old`?e.old_lineno:e.new_lineno],digits:t,tint:r}),(0,L.jsx)(`span`,{className:`whitespace-pre pr-3`,children:(0,L.jsx)(jt,{line:e})})]})}function Nt({cells:e,digits:t,side:n,border:r}){return(0,L.jsx)(`div`,{className:`min-w-0 flex-none overflow-x-auto md:flex-1 md:basis-1/2 ${r?`border-t border-ink-800 md:border-t-0 md:border-l`:``}`,children:(0,L.jsx)(`div`,{className:`w-max min-w-full`,children:e.map((e,r)=>(0,L.jsx)(Mt,{line:e,digits:t,side:n},r))})})}function Pt({lines:e,digits:t}){let n=it(e);return(0,L.jsxs)(`div`,{className:`flex flex-col md:flex-row`,children:[(0,L.jsx)(Nt,{cells:n.map(e=>e.left),digits:t,side:`old`,border:!1}),(0,L.jsx)(Nt,{cells:n.map(e=>e.right),digits:t,side:`new`,border:!0})]})}function Ft({diff:e,split:t}){let n=mt(e.hunks);return(0,L.jsxs)(`div`,{className:`p-1`,children:[e.hunks.length===0&&(0,L.jsx)(`p`,{className:`p-3 text-ink-400`,children:`No changes.`}),e.hunks.map((e,r)=>{let i=(0,L.jsxs)(`div`,{className:`bg-ink-850 px-3 py-0.5 text-ink-400`,children:[e.file_path?`${e.file_path} `:``,e.header]});return(0,L.jsx)(`div`,{"data-hunk":r,className:`mb-2`,children:t?(0,L.jsxs)(L.Fragment,{children:[i,(0,L.jsx)(Pt,{lines:e.lines,digits:n})]}):(0,L.jsxs)(`div`,{className:`w-max min-w-full`,children:[i,e.lines.map((e,t)=>{let r=Qe(e.kind);return(0,L.jsxs)(`div`,{className:`flex ${r}`,children:[(0,L.jsx)(At,{nos:[e.old_lineno,e.new_lineno],digits:n,tint:r}),(0,L.jsx)(`span`,{className:`whitespace-pre pr-3`,children:(0,L.jsx)(jt,{line:e})})]},t)})]})},r)}),e.truncated&&(0,L.jsx)(`p`,{className:`p-3 text-accent`,children:`Diff truncated — it exceeded the server's size ceiling.`})]})}var It=[`failed to fetch dynamically imported module`,`error loading dynamically imported module`,`importing a module script failed`,`unable to preload css`];function Lt(e){let t=e instanceof Error?e.message:typeof e==`string`?e:``;if(!t)return!1;let n=t.toLowerCase();return It.some(e=>n.includes(e))}var Rt=class extends v.Component{state={error:null,failed:!1};static getDerivedStateFromError(e){return{error:e,failed:!0}}componentDidCatch(e,t){console.error(`nightcrow: a subtree failed to render`,e,t)}render(){return this.state.failed?(0,L.jsx)(zt,{chunk:Lt(this.state.error),region:this.props.region,className:this.props.className}):this.props.children}};function zt({chunk:e,region:t,className:n}){return(0,L.jsxs)(`div`,{role:`alert`,className:`h-full min-h-0 flex-col items-start gap-3 p-4 text-ink-400 ${n??`flex`}`,children:[e?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`p`,{className:`text-accent`,children:`Part of the app could not be loaded.`}),(0,L.jsx)(`p`,{children:`Most likely the server was updated while this tab was open, and reloading picks up the current version. If the reload fails too, the server is not reachable from here. Either way nothing on the server is affected — the session, its repositories, and its terminals are untouched.`})]}):(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`p`,{className:`text-removed`,children:t?`The ${t} could not be rendered.`:`Something went wrong.`}),(0,L.jsx)(`p`,{children:`The details are in the browser console.`})]}),(0,L.jsx)(`button`,{onClick:()=>window.location.reload(),className:`rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:border-accent hover:text-accent`,children:`Reload`})]})}var Bt=`modulepreload`,Vt=function(e,t){return new URL(e,t).href},Ht={},Ut=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=Vt(t,n),t=s(t),t in Ht)return;Ht[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:Bt,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Wt=(0,v.lazy)(()=>Ut(()=>import(`./Markdown-Do6z-tD8.js`).then(e=>({default:e.MarkdownView})),__vite__mapDeps([0,1]),import.meta.url)),Gt=(0,v.lazy)(()=>Ut(()=>import(`./Html-B6C-L9Iq.js`).then(e=>({default:e.HtmlView})),[],import.meta.url));function Kt({lines:e}){let t=pt(e.length);return(0,L.jsx)(`pre`,{className:`w-max min-w-full py-2 text-ink-200`,children:e.map((e,n)=>(0,L.jsxs)(`div`,{"data-line":n+1,className:`flex`,children:[(0,L.jsx)(At,{nos:[n+1],digits:t}),(0,L.jsx)(`span`,{className:`whitespace-pre pr-3`,children:e.length===0?` `:e.map((e,t)=>(0,L.jsx)(`span`,{style:{color:e.c},children:e.t},t))})]},n))})}function qt({repo:e,pane:t,previewRendered:n,setPreviewRendered:r,filesMax:i,setMaximized:a,showOtherFace:o,status:s,className:c=``}){let l=at(),u=(0,v.useRef)(null),d=t.kind===`file`?t.anchor:void 0,f=(0,v.useRef)(null),p=t=>`${e??``}\u0000${St(t)}`,m=()=>{let e=u.current;if(!e)return 0;let n=e.getBoundingClientRect().top,r=Array.from(e.querySelectorAll(`[data-hunk]`),t=>t.getBoundingClientRect().top-n+e.scrollTop);return t.kind===`diff`&&t.source&&(f.current={key:p(t.source),top:e.scrollTop,left:e.scrollLeft}),vt(r,e.scrollTop)};return(0,v.useEffect)(()=>{let e=u.current;if(!e)return;if(t.kind===`diff`&&t.source){let n=f.current;n&&n.key===p(t.source)&&(e.scrollTop=n.top,e.scrollLeft=n.left,f.current=null);return}if(d===void 0||t.kind!==`file`)return;let n=_t(d,t.value.lines.length);if(n===null)return;let r=e.querySelector(`[data-line="${n}"]`);r&&(e.scrollTop+=r.getBoundingClientRect().top-e.getBoundingClientRect().top)},[t,d]),(0,L.jsxs)(`section`,{className:`min-h-0 min-w-0 flex-col ${c}`,children:[(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400`,children:[t.kind===`file`&&(0,L.jsx)(Ue,{path:t.value.path}),(0,L.jsxs)(`div`,{className:`ml-auto flex shrink-0 items-center gap-1`,children:[yt(t)&&(0,L.jsx)(`button`,{onClick:()=>o(m()),"aria-pressed":t.kind===`file`,title:t.kind===`file`?`Back to the diff`:`Open the whole file at this change`,"aria-label":t.kind===`file`?`Back to the diff`:`Open the whole file at this change`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${t.kind===`file`?`text-accent`:``}`,children:(0,L.jsx)(kt,{})}),t.kind===`diff`&&(0,L.jsx)(`button`,{onClick:l.toggle,"aria-pressed":l.layout===`split`,title:l.layout===`split`?`Switch to unified diff`:`Switch to split diff`,"aria-label":l.layout===`split`?`Switch to unified diff`:`Switch to split diff`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${l.layout===`split`?`text-accent`:``}`,children:(0,L.jsx)(wt,{})}),t.kind===`file`&&ut(t.value.path)&&(0,L.jsx)(`button`,{onClick:()=>r(e=>!e),"aria-pressed":n,title:n?`Show raw source`:`Show the rendered page`,"aria-label":n?`Show raw source`:`Show the rendered page`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${n?`text-accent`:``}`,children:(0,L.jsx)(Et,{})}),(0,L.jsx)(`button`,{onClick:()=>a(i?`none`:`files`),"aria-pressed":i,title:i?`Restore the layout`:`Maximize the file pane`,"aria-label":i?`Restore the layout`:`Maximize the file pane`,className:`hidden shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent md:flex`,children:(0,L.jsx)(Ct,{maximized:i})})]})]}),(0,L.jsxs)(`div`,{ref:u,className:`min-h-0 flex-1 overflow-auto`,children:[t.kind===`empty`&&(0,L.jsx)(`p`,{className:`p-4 text-ink-400`,children:s===null?`Loading…`:`Select a file or commit.`}),t.kind===`file`&&(0,L.jsxs)(L.Fragment,{children:[ut(t.value.path)&&n?(0,L.jsx)(Rt,{region:`preview`,children:(0,L.jsx)(v.Suspense,{fallback:(0,L.jsx)(`p`,{className:`p-4 text-ink-400`,children:`Rendering…`}),children:lt(t.value.path)&&e!==null?(0,L.jsx)(Gt,{src:k.previewUrl(e,t.value.path,t.source?.kind===`commit`?t.source.oid:void 0)}):(0,L.jsx)(Wt,{source:dt(t.value.lines)})})},t.value.path):(0,L.jsx)(Kt,{lines:t.value.lines}),t.value.truncated&&(0,L.jsx)(`p`,{className:`p-3 text-accent`,children:`File truncated — it exceeded the server's size ceiling.`})]}),t.kind===`diff`&&(0,L.jsx)(Ft,{diff:t.value,split:l.layout===`split`})]})]})}var Jt=[{key:`files`,label:`Repo`,icon:ge},{key:`diff`,label:`Content`,icon:_e},{key:`terminal`,label:`Terminal`,icon:ve}];function Yt({view:e,onSelect:t}){return(0,L.jsx)(`nav`,{"aria-label":`Switch view`,className:`flex shrink-0 items-stretch border-t border-ink-700 bg-ink-900 md:hidden`,children:Jt.map(({key:n,label:r,icon:i})=>(0,L.jsxs)(`button`,{onClick:()=>t(n),"aria-current":e===n?`page`:void 0,className:`flex min-h-11 flex-1 flex-col items-center justify-center gap-0.5 py-1 text-[11px] ${e===n?`text-accent shadow-[inset_0_2px_0_0_var(--color-accent)]`:`text-ink-400`}`,children:[(0,L.jsx)(i,{className:`h-5 w-5`}),r]},n))})}var Xt=(0,v.lazy)(()=>Ut(()=>import(`./Terminal-D3YT4ndj.js`).then(e=>({default:e.TerminalPanel})),[],import.meta.url));function Zt({repository:{id:e,current:t,status:n},sidebar:r,filePane:i,layout:{sidebarWidth:a,sidebarRef:o,draggingSidebar:s,onSidebarDragStart:c,onSidebarDragMove:l,onSidebarDragEnd:u,onSidebarDragCancel:d,upperRef:f,lowerRef:p,draggingUpper:m,onUpperDragStart:h,onUpperDragMove:g,onUpperDragEnd:_,onUpperDragCancel:y,maximized:b,setMaximized:x,mobileView:S,setMobileView:C}}){let w=b===`files`;return(0,v.useEffect)(()=>d,[e,d]),(0,v.useEffect)(()=>y,[y]),(0,L.jsxs)(L.Fragment,{children:[s&&(0,L.jsx)(`div`,{className:`fixed inset-0 z-50 cursor-col-resize`}),m&&(0,L.jsx)(`div`,{className:`fixed inset-0 z-50 cursor-row-resize`}),(0,L.jsxs)(`main`,{ref:f,className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${S===`terminal`?`hidden md:grid`:``} ${s||m?`select-none`:``}`,style:{"--nc-sidebar":w?`0px`:`min(${a}px, ${Ee*100}vw)`},children:[(0,L.jsx)(rt,{...r,repo:e,status:n,sidebarRef:o,draggingSidebar:s,onSidebarDragStart:c,onSidebarDragMove:l,onSidebarDragEnd:u,onSidebarDragCancel:d,filesMax:w,mobileView:S},e),(0,L.jsx)(qt,{...i,filesMax:w,setMaximized:x,status:n,className:S===`diff`?`flex`:`hidden md:flex`})]}),(0,L.jsx)(Rt,{region:`terminal panel`,className:S===`terminal`?`flex`:`hidden md:flex`,children:(0,L.jsx)(v.Suspense,{fallback:null,children:(0,L.jsx)(Xt,{repo:e,maximized:b===`terminal`,onToggleMaximized:()=>x(e=>e===`terminal`?`none`:`terminal`),className:S===`terminal`?`flex`:`hidden md:flex`,sectionRef:p,showDivider:b===`none`,draggingUpper:m,onUpperDragStart:h,onUpperDragMove:g,onUpperDragEnd:_,onUpperDragCancel:y})})}),(0,L.jsx)(Yt,{view:S,onSelect:C}),(0,L.jsxs)(`footer`,{className:`flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400`,children:[(0,L.jsx)(`span`,{className:`min-w-0 truncate`,children:t?.display_path}),n?.branch&&(0,L.jsx)(`span`,{className:`min-w-0 max-w-[50%] truncate text-accent`,children:n.branch}),n?.tracking&&(0,L.jsxs)(`span`,{className:`shrink-0`,children:[`↑`,n.tracking.ahead,` ↓`,n.tracking.behind]}),(0,L.jsx)(`span`,{className:`ml-auto shrink-0`,children:n?(0,L.jsx)(`span`,{className:`text-added`,children:`● live`}):`connecting…`})]})]})}function Qt(e,t){return e?`grid-rows-[auto_minmax(0,1fr)_auto_auto] ${t===`terminal`?`md:grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]`:t===`files`?`md:grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]`:`md:grid-rows-[auto_minmax(0,var(--nc-upper))_minmax(0,var(--nc-lower))_auto]`}`:`grid-rows-[auto_1fr]`}var $t=1e3,en=3,tn=2e3;function nn(e,t){let[n,r]=(0,v.useState)(!1),i=(0,v.useRef)(!1),a=(0,v.useRef)(!1);(0,v.useEffect)(()=>(a.current=!1,()=>{a.current=!0}),[]);let o=(0,v.useCallback)(async t=>{for(;!a.current;){if(await new Promise(e=>setTimeout(e,$t)),a.current)return;let n;try{n=await k.cloneStatus(t)}catch(e){if(x(e)){i.current=!1,a.current||r(!1);return}if(e instanceof b&&e.status===404){if(a.current)return;P.error(`the clone's progress is no longer available`),i.current=!1,r(!1);return}continue}if(a.current)return;if(n.state===`done`){try{let t=await k.open(n.path);if(a.current)return;e(t)}catch(e){if(a.current)return;P.error(e instanceof Error?e.message:`could not open`)}finally{i.current=!1,a.current||r(!1)}return}if(n.state===`failed`){P.error(n.message),i.current=!1,r(!1);return}}},[e]),s=(0,v.useCallback)(async(e=()=>!1)=>{for(let t=0;t0&&await new Promise(e=>setTimeout(e,tn)),i.current||e()||a.current)return;let n;try{({job:n}=await k.runningClone())}catch(e){if(x(e))return;continue}if(n===null||e()||a.current||i.current)return;i.current=!0,r(!0),o(n);return}},[o]);return(0,v.useEffect)(()=>{if(!t)return;let e=!1;return s(()=>e),()=>{e=!0}},[t,s]),{busy:n,start:(0,v.useCallback)(async(e,t)=>{if(!(!t.trim()||i.current)){i.current=!0,r(!0);try{let{job:n}=await k.clone(e,t.trim());await o(n)}catch(e){if(i.current=!1,a.current)return;let t=e instanceof b&&e.status>=400;P.error(t?e.message:`could not confirm the clone started — check this folder before retrying`),r(!1),s()}}},[o,s])}}function rn(e,t,n,r=!1){return r&&n&&t.includes(n)?n:e&&t.includes(e)?e:n&&t.includes(n)?n:t[0]??null}function an(e){let t=!1,n=null,r=()=>{if(t||n===null)return;let i=n;n=null,t=!0,e(i).catch(()=>{}).finally(()=>{t=!1,r()})};return e=>{n=e,r()}}function on(e,t,n){if(t===n)return e;let r=e.indexOf(t),i=e.indexOf(n);if(r===-1||i===-1)return e;let a=e.filter(e=>e!==t),o=a.indexOf(n),s=rwindow.location.reload()}}))}var hn=3e3;function gn({authed:e,setAuthed:t,handle:n,adoptAccent:r,adoptSidebarWidth:i,adoptUpperPct:a,adoptMaximized:o,adoptViews:s,draggingRef:c,upperDraggingRef:l,accentWrites:u,sidebarWrites:d,upperPctWrites:f,maximizedWrites:p,viewWrites:m,resumeTick:h,orderWrites:g,repoDraggingRef:_,reorderInFlightRef:y,pendingReorderRef:b}){let[S,w]=(0,v.useState)([]),[T,E]=(0,v.useState)(null),[D,O]=(0,v.useState)(null),[ee,te]=(0,v.useState)(null),[A,j]=(0,v.useState)(!1),[ne,re]=(0,v.useState)(!0),{current:M}=(0,v.useRef)(an(k.setActiveRepo)),N=(0,v.useRef)(null),ie=(0,v.useRef)(null);return(0,v.useEffect)(()=>{if(e===!1)return;let h=!1,v,S=new AbortController,T=()=>{let e=u.current,D=d.current,ee=f.current,A=p.current,ne=m.current,M=g.current;return k.repos(S.signal).then(n=>{let{repos:x,hot:S,accent:C,sidebar_width:k,upper_pct:ae,active_repo:oe,maximized:P,last_view:F,now_ms:I,can_clone:L,viewer_build:se}=n;if(h)return;mn(se),O(S),re(L),te(e=>Ke(e,I,Date.now())),u.current===e&&r(C),d.current===D&&!c.current&&i(k),f.current===ee&&!l.current&&a(ae),p.current===A&&o(P),m.current===ne&&s(F??{},x.map(e=>e.id)),t(!0),j(!0);let ce=y.current||b.current!==null;g.current===M&&!_.current&&!ce?w(x):w(e=>{let t=sn(x.map(e=>e.id),e.map(e=>e.id)),n=new Map(x.map(e=>[e.id,e]));return t.map(e=>n.get(e)).filter(Boolean)});let le=x.map(e=>e.id),ue=oe!==N.current;N.current=oe??null,ue&&oe&&le.includes(oe)&&(ie.current=oe),E(e=>rn(e,le,oe,ue)),h||(v=setTimeout(T,hn))}).catch(e=>{if(!h){if(x(e)){t(!1),j(!1);return}else C(e)||n(e);v=setTimeout(T,hn)}})};return T(),()=>{h=!0,S.abort(),v&&clearTimeout(v)}},[e,t,n,r,i,a,h,u,d,f,p,m,c,l,o,s,g,_,y,b]),(0,v.useEffect)(()=>{if(!T){ie.current=null;return}T!==ie.current&&(ie.current=null,M(T))},[T,M]),{repos:S,setRepos:w,repo:T,setRepo:E,hot:D,clockSkewMs:ee,reposLoaded:A,canClone:ne}}var _n=4;function vn({ids:e,onReorder:t,draggingRef:n}){let r=(0,v.useRef)(null),i=(0,v.useRef)(null),a=(0,v.useRef)(null),[o,s]=(0,v.useState)(null),[c,l]=(0,v.useState)(null);return{dragging:o,target:c,onStart:(t,a)=>{t.target.closest(`button[data-tab-close]`)||t.button!==0||e.length<2||(r.current=a,i.current={x:t.clientX,y:t.clientY},n.current=!1)},onMove:e=>{let t=r.current,o=i.current;if(t===null||o===null)return;if(!n.current&&e.buttons===0){r.current=null,i.current=null;return}if(!n.current&&Math.hypot(e.clientX-o.x,e.clientY-o.y)<_n)return;n.current||e.currentTarget.setPointerCapture(e.pointerId),n.current=!0,s(t);let c=(document.elementFromPoint(e.clientX,e.clientY)?.closest(`[data-repo-id]`))?.getAttribute(`data-repo-id`)??null,u=c===t?null:c;a.current=u,l(u)},onEnd:()=>{let o=r.current,c=a.current;o!==null&&n.current&&c!==null&&t(on(e,o,c)),r.current=null,i.current=null,a.current=null,n.current=!1,s(null),l(null)}}}function yn({repos:e,setRepos:t,handle:n,writesRef:r,draggingRef:i,inFlightRef:a,pendingRef:o}){let s=(0,v.useCallback)(()=>{if(a.current||o.current===null)return;let e=o.current;o.current=null,a.current=!0;let i=r.current;k.reorderRepos(e).then(e=>{r.current===i&&t(e)}).catch(n).finally(()=>{a.current=!1,s()})},[n,t]),c=(0,v.useCallback)(e=>{r.current+=1,t(t=>{let n=sn(t.map(e=>e.id),e),r=new Map(t.map(e=>[e.id,e]));return n.map(e=>r.get(e)).filter(Boolean)}),o.current=e,s()},[s,t]);return{...vn({ids:e.map(e=>e.id),onReorder:c,draggingRef:i}),writesRef:r,draggingRef:i,inFlightRef:a,pendingRef:o}}function bn({authed:e,setAuthed:t,handle:n,resumeTick:r,adoptAccent:i,adoptSidebarWidth:a,adoptUpperPct:o,adoptMaximized:s,adoptViews:c,accentWrites:l,sidebarWrites:u,upperPctWrites:d,maximizedWrites:f,viewWrites:p,draggingRef:m,upperDraggingRef:h}){let g=(0,v.useRef)(0),_=(0,v.useRef)(!1),y=(0,v.useRef)(!1),b=(0,v.useRef)(null),x=gn({authed:e,setAuthed:t,handle:n,adoptAccent:i,adoptSidebarWidth:a,adoptUpperPct:o,adoptMaximized:s,adoptViews:c,draggingRef:m,upperDraggingRef:h,accentWrites:l,sidebarWrites:u,upperPctWrites:d,maximizedWrites:f,viewWrites:p,resumeTick:r,orderWrites:g,repoDraggingRef:_,reorderInFlightRef:y,pendingReorderRef:b}),{dragging:S,target:C,onStart:w,onMove:T,onEnd:E}=yn({repos:x.repos,setRepos:x.setRepos,handle:n,writesRef:g,draggingRef:_,inFlightRef:y,pendingRef:b});return{...x,orderWrites:g,draggingRepo:S,dragOverRepo:C,onRepoDragStart:w,onRepoDragMove:T,onRepoDragEnd:E}}function xn(e,t){let n=e.indexOf(t);return n===-1?e[0]??null:e[n+1]??e[n-1]??null}function Sn({repo:e,repos:t,setRepos:n,setRepo:r,setPane:i,setTab:a,setPickerOpen:o,handle:s,orderWrites:c}){let l=(0,v.useRef)(t);l.current=t;let u=(0,v.useRef)(e);return u.current=e,{selectOpenedRepo:(0,v.useCallback)(e=>{c.current+=1,n(t=>t.some(t=>t.id===e.id)?t:[...t,e]),r(e.id),e.id!==u.current&&(i({kind:`empty`}),a(`status`)),o(!1)},[n,r,i,a,o,c]),closeRepo:(0,v.useCallback)(async e=>{try{await k.close(e),c.current+=1;let t=xn(l.current.map(e=>e.id),e);n(t=>t.filter(t=>t.id!==e)),r(n=>n===e?t:n)}catch(e){s(e)}},[n,r,s,c])}}function Cn(e,t,n,r,i){(0,v.useLayoutEffect)(()=>{t&&(e.some(e=>e.oid===t.commit.oid)||(r(),n(null),i()))},[e,t,n,r,i])}function wn(e,t,n){let r=!e.truncated||e.head===void 0,i=t[0]?.oid,a=i===void 0?-1:e.commits.findIndex(e=>e.oid===i),o=a<0?[]:e.commits.slice(a);return a>=0&&o.length<=t.length&&o.every((e,n)=>e.oid===t[n].oid)?{commits:a===0?t:[...e.commits.slice(0,a),...t],anchor:e.head??null,done:n||r,mode:`prepend`}:{commits:e.commits,anchor:e.head??null,done:r,mode:`replace`}}function Tn({repo:e,authed:t,tab:n,filter:r,head:i,handle:a}){let[o,s]=(0,v.useState)([]),[c,l]=(0,v.useState)(!1),[u,d]=(0,v.useState)(!1),f=(0,v.useRef)(null),p=(0,v.useRef)(!1),m=(0,v.useRef)(!1),h=(0,v.useRef)(0),g=(0,v.useRef)(void 0),_=(0,v.useRef)(void 0),y=(0,v.useCallback)(()=>{h.current+=1,p.current=!1,m.current=!1,s([]),f.current=null,l(!1),d(!1),g.current=void 0,_.current=void 0},[]),[b,x]=(0,v.useState)(null),S=(0,v.useRef)(o);S.current=o;let C=(0,v.useRef)(c);C.current=c;let w=(0,v.useRef)(i);w.current=i;let T=(0,v.useCallback)(async t=>{if(!e)return;_.current=w.current,h.current+=1;let n=h.current;p.current=!0,m.current=!0;try{let r=await k.log(e);if(n!==h.current)return;let i=t??{commits:S.current,done:C.current},a=wn(r,i.commits,i.done);s(a.commits),f.current=a.anchor,g.current=a.anchor,a.anchor===w.current&&(_.current=void 0),l(a.done),d(!1)}catch(e){n===h.current&&(_.current=void 0,a(e),d(!0))}finally{n===h.current&&(p.current=!1,m.current=!1)}},[e,a]),E=(0,v.useCallback)(async()=>{if(!e||p.current)return;p.current=!0;let t=h.current;try{let n=f.current,r=await k.log(e,n===null?void 0:{from:n,skip:S.current.length});if(t!==h.current)return;if(s(e=>[...e,...r.commits]),f.current=r.head??null,l(!r.truncated||r.head===void 0),n===null){g.current=r.head??null;let e=w.current;e!==void 0&&e!==(r.head??null)&&T({commits:r.commits,done:!r.truncated||r.head===void 0})}}catch(e){t===h.current&&(a(e),d(!0))}finally{t===h.current&&(p.current=!1)}},[e,a,T]);(0,v.useEffect)(()=>{!e||!t||n!==`log`||o.length===0&&!c&&!u&&E()},[e,t,n,o.length,c,u,E]),(0,v.useEffect)(()=>{if(!e||!t||n!==`log`||i===void 0||u)return;let r=g.current;if(r!==void 0){if(r===i){m.current||(_.current=void 0);return}_.current!==i&&T()}},[e,t,n,i,u,T]);let D=o.filter(e=>e.summary.toLowerCase().includes(r.toLowerCase())),O=r!==``,ee=(0,v.useRef)(null);return(0,v.useEffect)(()=>{let e=ee.current;if(!e)return;let t=new IntersectionObserver(e=>{e.some(e=>e.isIntersecting)&&E()},{root:e.closest(`ul`),rootMargin:`400px`});return t.observe(e),()=>t.disconnect()},[E,c,u,O,b,n,D.length]),{commits:o,logDone:c,logStalled:u,setLogStalled:d,commitDrillDown:b,setCommitDrillDown:x,resetLog:y,logSentinelRef:ee,visibleCommits:D,logPagingPaused:O}}function En({repo:e,handle:t,setPane:n,paneRequestRef:r,setCommitDrillDown:i,setMobileView:a,setPreviewRendered:o,statusRef:s}){let c=(0,v.useCallback)(e=>{let t=s.current?.files.find(t=>t.path===e);return t?bt(t):!1},[s]);return{openDiff:(0,v.useCallback)((i,o)=>{if(!e)return;o?.restoring||a(`diff`);let s=r.current+=1;k.diff(e,i).then(e=>{s===r.current&&n({kind:`diff`,value:e,source:c(i)&&xt(e)?{kind:`workdir`,path:i}:void 0})}).catch(e=>{if(s!==r.current)return x(e)?t(e):void 0;if(!o?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a,c]),openFile:(0,v.useCallback)((i,s)=>{if(!e)return;s?.restoring||a(`diff`),o(!0);let c=r.current+=1;k.file(e,i).then(e=>{c===r.current&&n({kind:`file`,value:e})}).catch(e=>{if(c!==r.current)return x(e)?t(e):void 0;if(!s?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a,o]),openCommit:(0,v.useCallback)(i=>{if(!e)return;a(`diff`);let o=r.current+=1;k.commit(e,i).then(e=>{o===r.current&&n({kind:`diff`,value:e})}).catch(e=>{o===r.current&&t(e)})},[e,t,n,r,a]),openCommitFileDiff:(0,v.useCallback)((i,o,s)=>{if(!e)return;s?.restoring||a(`diff`);let c=r.current+=1;k.commitFileDiff(e,i,o).then(e=>{c===r.current&&n({kind:`diff`,value:e,source:xt(e)?{kind:`commit`,oid:i,path:o}:void 0})}).catch(e=>{if(c!==r.current)return x(e)?t(e):void 0;if(!s?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a]),openCommitFiles:(0,v.useCallback)(async o=>{if(!e)return;a(`diff`);let s=r.current+=1;try{let t=await k.commitFiles(e,o.oid);if(s!==r.current)return;if(i({commit:o,...t}),t.files.length===0){n({kind:`empty`});return}let a=await k.commit(e,o.oid);s===r.current&&n({kind:`diff`,value:a})}catch(e){s===r.current&&t(e)}},[e,t,n,r,i,a]),showOtherFace:(0,v.useCallback)((i,a=0)=>{let s=yt(i);if(!e||!s)return;let{source:l}=s,u=s.want===`file`,d=u&&i.kind===`diff`?ht(i.value,a):null,f=r.current+=1,p=l.kind===`workdir`?u?k.file(e,l.path):k.diff(e,l.path):u?k.commitFile(e,l.oid,l.path):k.commitFileDiff(e,l.oid,l.path);u&&o(!1),p.then(e=>{f===r.current&&n(u?{kind:`file`,value:e,source:l,anchor:d===null?void 0:gt(d)+1}:{kind:`diff`,value:e,source:xt(e)&&(l.kind!==`workdir`||c(l.path))?l:void 0})}).catch(e=>{f===r.current&&t(e)})},[e,t,n,r,o,c])}}var Dn=[`status`,`log`,`tree`];function On(){return{tab:`status`,file:null,tree_expanded:[]}}function kn(e,t){return{path:e,commit:null,face:t}}function An(e,t,n){return{path:t,commit:e,face:n}}function jn(e){return[...e].sort().slice(0,200)}function Mn(e){let t=e?.file;return!t||!t.path?{kind:`none`}:t.commit?{kind:`commitDiff`,oid:t.commit,path:t.path}:t.face===`source`?{kind:`file`,path:t.path}:{kind:`diff`,path:t.path}}function Nn(e){let t=e?.tab;return t&&Dn.includes(t)?t:`status`}function Pn(e,t){return e?e.tab===t.tab&&e.tree_expanded.length===t.tree_expanded.length&&e.tree_expanded.every((e,n)=>e===t.tree_expanded[n])&&Fn(e.file,t.file):!1}function Fn(e,t){return!e||!t?e===t:e.path===t.path&&e.commit===t.commit&&e.face===t.face}function In({repo:e,known:t,remembered:n,latest:r,remember:i,setTab:a,openDiff:o,openFile:s,openCommitFileDiff:c}){let l=(0,v.useRef)(null),u=(0,v.useRef)(null),d=(0,v.useRef)(new Map),[f,p]=(0,v.useState)(!1),m=(0,v.useRef)(!1);(0,v.useEffect)(()=>{if(u.current!==e&&(u.current=e,l.current=null,m.current=!1,p(!1)),!e||!t||l.current===e)return;l.current=e;let r=d.current.get(e);d.current.delete(e);let f=r?{...n??On(),...r}:n;if(r&&i(e,f),m.current)return;a(Nn(f));let h=Mn(f);if(h.kind===`none`)return;let g={restoring:!0};h.kind===`diff`?o(h.path,g):h.kind===`file`?s(h.path,g):c(h.oid,h.path,g)},[e,t,n,i,a,o,s,c]);let h=(0,v.useCallback)(t=>{if(e){if(m.current=!0,p(!0),l.current!==e){let n=d.current.get(e);d.current.set(e,{...n,...t});return}i(e,{...r(e)??On(),...t})}},[e,r,i]);return{touched:f,noteTab:(0,v.useCallback)(e=>h({tab:e}),[h]),noteFile:(0,v.useCallback)(e=>h({file:e}),[h]),noteTree:(0,v.useCallback)(e=>h({tree_expanded:jn(e)}),[h])}}function Ln({repo:e,authed:t,resumeTick:n,tab:r,pane:i,setPane:a,handle:o,paneRequestRef:s}){let[c,l]=(0,v.useState)(null),u=(0,v.useRef)(i);u.current=i;let d=(0,v.useRef)(r);return d.current=r,(0,v.useLayoutEffect)(()=>{l(null)},[e,t]),(0,v.useEffect)(()=>{if(!(!e||!t))return te(e,l)},[e,t,n]),(0,v.useEffect)(()=>{if(!e||!c)return;let t=u.current;if(d.current!==`status`||t.kind!==`diff`)return;let n=t.value.path,r=c.files.find(e=>e.path===n);if(!r){a({kind:`empty`});return}let i=s.current,l=!0,f=()=>{let e=u.current;return l&&i===s.current&&e.kind===`diff`&&e.value.path===n};return k.diff(e,n).then(e=>{f()&&a({kind:`diff`,value:e,source:bt(r)&&xt(e)?{kind:`workdir`,path:n}:void 0})}).catch(e=>{f()&&o(e)}),()=>{l=!1}},[c,e,o,u,d,s,a]),{status:c,paneRef:u,tabRef:d}}function Rn({repo:e,repos:t,authed:n,hot:r,clockSkewMs:i,resumeTick:a,handle:o,shell:s,viewKnown:c,rememberedView:l,latestView:u,rememberView:d,maximizedPanelOf:f,setMaximizedFor:p}){let[m,h]=(0,v.useState)(`status`),[g,_]=(0,v.useState)(``),[y,b]=(0,v.useState)(!1),[x,S]=(0,v.useState)({kind:`empty`}),[C,w]=(0,v.useState)(`files`),[T,E]=(0,v.useState)(!0),[D,O]=(0,v.useState)(e);D!==e&&(O(e),S({kind:`empty`}),h(`status`));let ee=(0,v.useRef)(0),k=(0,v.useCallback)(()=>{ee.current+=1},[]),te=(0,v.useCallback)(()=>S({kind:`empty`}),[]),{status:A}=Ln({repo:e,authed:n,resumeTick:a,tab:m,pane:x,setPane:S,handle:o,paneRequestRef:ee}),j=r?.enabled?r.window_secs*1e3:0,ne=Xe(A?.files,j,i??0),re=f(e),M=(0,v.useCallback)(t=>p(e,t),[e,p]),N=Tn({repo:e,authed:n,tab:m,filter:g,head:A?A.head??null:void 0,handle:o}),ie=(0,v.useRef)(A);ie.current=A;let ae=En({repo:e,handle:o,setPane:S,paneRequestRef:ee,setCommitDrillDown:N.setCommitDrillDown,setMobileView:w,setPreviewRendered:E,statusRef:ie}),oe=In({repo:e,known:c,remembered:l,latest:u,remember:d,setTab:h,openDiff:ae.openDiff,openFile:ae.openFile,openCommitFileDiff:ae.openCommitFileDiff}),{noteFile:P,noteTab:F,noteTree:I}=oe,L=(0,v.useCallback)(e=>{F(e),h(e)},[F]),se=(0,v.useCallback)(()=>{P(null),te()},[P,te]),ce=(0,v.useMemo)(()=>({openDiff:e=>{P(kn(e,`diff`)),ae.openDiff(e)},openFile:e=>{P(kn(e,`source`)),ae.openFile(e)},openCommit:e=>{P(null),ae.openCommit(e)},openCommitFiles:e=>(P(null),ae.openCommitFiles(e)),openCommitFileDiff:(e,t)=>{P(An(e,t,`diff`)),ae.openCommitFileDiff(e,t)}}),[ae,P]);(0,v.useLayoutEffect)(()=>{k(),N.setCommitDrillDown(null),N.resetLog()},[e,k,N.setCommitDrillDown,N.resetLog]),Cn(N.commits,N.commitDrillDown,N.setCommitDrillDown,k,se);let le=g.toLowerCase(),ue=(0,v.useMemo)(()=>(A?.files??[]).filter(e=>e.path.toLowerCase().includes(le)),[A?.files,le]),de=(0,v.useMemo)(()=>(N.commitDrillDown?.files??[]).filter(e=>e.path.toLowerCase().includes(le)||e.old_path?.toLowerCase().includes(le)),[N.commitDrillDown?.files,le]),fe=(0,v.useMemo)(()=>new Set(N.commits.slice(0,A?.tracking?.ahead??0).map(e=>e.oid)),[N.commits,A?.tracking?.ahead]);return{setPane:S,setTab:h,clearPane:te,maximized:re,repoShell:e?{repository:{id:e,current:t.find(t=>t.id===e),status:A},sidebar:{tab:m,filter:g,setFilter:_,filterOpen:y,setFilterOpen:b,files:ue,now:ne,hotWindowMs:j,...ae,...ce,setTab:L,authed:n,handle:o,bumpPaneRequest:k,...N,aheadOids:fe,visibleCommitFiles:de,restoreTree:l?.tree_expanded??[],restoreKnown:c,onTreeExpanded:I,clearPane:se,touched:oe.touched},filePane:{repo:e,pane:x,previewRendered:T,setPreviewRendered:E,showOtherFace:e=>{let t=yt(x);if(t){let e=t.want===`file`?`source`:`diff`;P(t.source.kind===`commit`?An(t.source.oid,t.source.path,e):kn(t.source.path,e))}ae.showOtherFace(x,e)}},layout:{...s,maximized:re,setMaximized:M,mobileView:C,setMobileView:w}}:null}}function zn(){let[e,t]=(0,v.useState)(0);return(0,v.useEffect)(()=>{let e=()=>{document.visibilityState===`visible`&&t(e=>e+1)};return document.addEventListener(`visibilitychange`,e),window.addEventListener(`online`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`online`,e)}},[]),e}var Bn=[{name:`yellow`,color:`#d9a441`},{name:`cyan`,color:`#03c4db`},{name:`green`,color:`#77c47a`},{name:`magenta`,color:`#dc8fd5`},{name:`blue`,color:`#87acfd`}],Vn=`nightcrow.viewer.accent`;function Hn(e){if(!Number.isFinite(e))return 0;let t=Bn.length;return(Math.trunc(e)%t+t)%t}function Un(){try{let e=localStorage.getItem(Vn);return e===null?0:Hn(Number(e))}catch{return 0}}function Wn(e){try{localStorage.setItem(Vn,String(e))}catch{}}function Gn(){let[e,t]=(0,v.useState)(Un);(0,v.useLayoutEffect)(()=>{document.documentElement.style.setProperty(`--color-accent`,Bn[e].color)},[e]);let n=(0,v.useCallback)(()=>{t(e=>{let t=Hn(e+1);return Wn(t),k.setAccent(t).catch(()=>{}),t})},[]),r=(0,v.useCallback)(e=>{t(t=>{let n=Hn(e);return n===t?t:(Wn(n),n)})},[]);return{accent:Bn[e],next:Bn[Hn(e+1)],cycle:n,adopt:r}}function Kn(e){return Number.isFinite(e)?Math.min(Math.max(e,20),85):55}function qn(e){return Math.round(Kn(e))}function Jn(e,t,n,r){let i=n-t;return Kn(i<=0?r:(e-t)/i*100)}var Yn=`nightcrow.upperPct`;function Xn(){try{let e=Number(localStorage.getItem(Yn));return Number.isFinite(e)&&e>0?qn(e):55}catch{return 55}}function Zn(e){try{localStorage.setItem(Yn,String(e))}catch{}}function Qn(){let[e,t]=(0,v.useState)(Xn);return{pct:e,resize:(0,v.useCallback)(e=>{t(Kn(e))},[]),commit:(0,v.useCallback)(e=>{let n=qn(e);t(n),Zn(n),k.setUpperPct(n).catch(()=>{})},[]),reset:(0,v.useCallback)(()=>{t(55),Zn(55),k.setUpperPct(55).catch(()=>{})},[]),adopt:(0,v.useCallback)(e=>{t(t=>{let n=qn(e);return n===t?t:(Zn(n),n)})},[])}}function $n(){let[e,t]=(0,v.useState)({}),n=(0,v.useRef)(e),r=(0,v.useCallback)(e=>{n.current=e,t(e)},[]),i=(0,v.useRef)(0),a=(0,v.useRef)(new Map),o=(0,v.useCallback)(e=>{let t=a.current.get(e);if(t)return t;let n=an(t=>k.setMaximized(e,t===`none`?null:t));return a.current.set(e,n),n},[]),s=(0,v.useCallback)((e,t)=>{if(e==null)return;let a=n.current,s=typeof t==`function`?t(a[e]??`none`):t;i.current+=1,o(e)(s);let{[e]:c,...l}=a;r(s===`none`?l:{...a,[e]:s})},[o,r]);return{panelOf:(0,v.useCallback)(t=>t!=null&&e[t]||`none`,[e]),setFor:s,adopt:(0,v.useCallback)(e=>{er(n.current,e)||r(e)},[r]),writes:i}}function er(e,t){let n=Object.keys(e);return n.length===Object.keys(t).length&&n.every(n=>e[n]===t[n])}function tr(){let[e,t]=(0,v.useState)({}),n=(0,v.useRef)(e),r=(0,v.useCallback)(e=>{n.current=e,t(e)},[]),i=(0,v.useRef)(0),a=(0,v.useRef)(new Map),o=(0,v.useCallback)(e=>{let t=a.current.get(e);if(t)return t;let n=an(t=>k.setRepoView(e,t));return a.current.set(e,n),n},[]),s=(0,v.useCallback)((e,t)=>{e!=null&&(Pn(n.current[e],t)||(i.current+=1,o(e)(t),r({...n.current,[e]:t})))},[o,r]),c=(0,v.useCallback)(t=>t==null?void 0:e[t],[e]),l=(0,v.useCallback)(e=>n.current[e],[]),u=(0,v.useRef)(new Set),[d,f]=(0,v.useState)(0);return{viewOf:c,rememberedFor:l,remember:s,adopt:(0,v.useCallback)((e,t)=>{let i=u.current;(i.size!==t.length||t.some(e=>!i.has(e)))&&(u.current=new Set(t),f(e=>e+1)),!nr(n.current,e)&&r(e)},[r]),covers:(0,v.useCallback)(e=>e!=null&&u.current.has(e),[d]),writes:i}}function nr(e,t){let n=Object.keys(e);return n.length===Object.keys(t).length&&n.every(n=>t[n]!==void 0&&Pn(e[n],t[n]))}function rr(){let{accent:e,next:t,cycle:n,adopt:r}=Gn(),{width:i,resize:a,commit:o,reset:s,adopt:c}=je(),{pct:l,resize:u,commit:d,reset:f,adopt:p}=Qn(),m=$n(),h=tr(),g=(0,v.useRef)(0),_=(0,v.useRef)(0),y=(0,v.useRef)(0);return{accent:e,next:t,cycle:(0,v.useCallback)(()=>{g.current+=1,n()},[n]),adoptAccent:r,accentWrites:g,sidebarWidth:i,resizeSidebar:a,commitSidebarWidth:(0,v.useCallback)(e=>{_.current+=1,o(e)},[o]),resetSidebarWidth:(0,v.useCallback)(()=>{_.current+=1,s()},[s]),bumpSidebarWrites:(0,v.useCallback)(()=>{_.current+=1},[]),adoptSidebarWidth:c,sidebarWrites:_,upperPct:l,resizeUpperPct:u,commitUpperPct:(0,v.useCallback)(e=>{y.current+=1,d(e)},[d]),resetUpperPct:(0,v.useCallback)(()=>{y.current+=1,f()},[f]),bumpUpperPctWrites:(0,v.useCallback)(()=>{y.current+=1},[]),adoptUpperPct:p,upperPctWrites:y,maximizedPanelOf:m.panelOf,setMaximizedFor:m.setFor,adoptMaximized:m.adopt,maximizedWrites:m.writes,viewOf:h.viewOf,rememberedViewFor:h.rememberedFor,rememberView:h.remember,adoptViews:h.adopt,viewCovers:h.covers,viewWrites:h.writes}}var ir=400;function ar({value:e,valueAt:t,onGestureStart:n,resize:r,commit:i,reset:a,axis:o}){let s=(0,v.useRef)(0),c=(0,v.useRef)(0),l=(0,v.useRef)(!1),u=(0,v.useRef)(!1),d=(0,v.useRef)(0),[f,p]=(0,v.useState)(!1);return{dragging:f,onDragStart:(0,v.useCallback)(t=>{t.button!==0||!t.isPrimary||n()&&(s.current=o===`x`?t.clientX:t.clientY,c.current=e,l.current=!0,u.current=!1,p(!0),t.currentTarget.setPointerCapture(t.pointerId),t.preventDefault())},[e,n,o]),onDragMove:(0,v.useCallback)(e=>{if(!l.current)return;let n=o===`x`?e.clientX:e.clientY;if(!u.current&&Math.abs(n-s.current)<3)return;let i=t(e);i!==null&&(u.current=!0,c.current=i,r(i))},[t,r,o]),onDragEnd:(0,v.useCallback)(()=>{if(!l.current)return;if(l.current=!1,p(!1),u.current){i(c.current),d.current=0;return}let e=Date.now();e-d.current{l.current=!1,u.current=!1,d.current=0,p(!1)},[]),draggingRef:l}}function or({sidebarRef:e,sidebarWidth:t,resizeSidebar:n,commitSidebarWidth:r,resetSidebarWidth:i,bumpSidebarWrites:a}){let o=(0,v.useRef)(0),s=(0,v.useCallback)(()=>{let t=e.current?.getBoundingClientRect().left;return t===void 0?!1:(o.current=t,a(),!0)},[e,a]),{dragging:c,onDragStart:l,onDragMove:u,onDragEnd:d,onDragCancel:f,draggingRef:p}=ar({value:t,valueAt:(0,v.useCallback)(e=>e.clientX-o.current,[]),onGestureStart:s,resize:n,commit:r,reset:i,axis:`x`});return{draggingSidebar:c,onSidebarDragStart:l,onSidebarDragMove:u,onSidebarDragEnd:d,onSidebarDragCancel:f,draggingRef:p}}function sr({upperRef:e,lowerRef:t,upperPct:n,resizeUpperPct:r,commitUpperPct:i,resetUpperPct:a,bumpUpperPctWrites:o}){let s=(0,v.useRef)(0),c=(0,v.useRef)(0),l=(0,v.useCallback)(()=>{let n=e.current?.getBoundingClientRect().top,r=t.current?.getBoundingClientRect().bottom;return n===void 0||r===void 0?!1:(s.current=n,c.current=r,o(),!0)},[e,t,o]),{dragging:u,onDragStart:d,onDragMove:f,onDragEnd:p,onDragCancel:m,draggingRef:h}=ar({value:n,valueAt:(0,v.useCallback)(e=>Jn(e.clientY,s.current,c.current,n),[n]),onGestureStart:l,resize:r,commit:i,reset:a,axis:`y`});return{draggingUpper:u,onUpperDragStart:d,onUpperDragMove:f,onUpperDragEnd:p,onUpperDragCancel:m,upperDraggingRef:h}}function cr(){let{accent:e,next:t,cycle:n,adoptAccent:r,accentWrites:i,sidebarWidth:a,resizeSidebar:o,commitSidebarWidth:s,resetSidebarWidth:c,bumpSidebarWrites:l,adoptSidebarWidth:u,sidebarWrites:d,upperPct:f,resizeUpperPct:p,commitUpperPct:m,resetUpperPct:h,bumpUpperPctWrites:g,adoptUpperPct:_,upperPctWrites:y,maximizedPanelOf:b,setMaximizedFor:x,adoptMaximized:S,maximizedWrites:C,viewOf:w,rememberedViewFor:T,rememberView:E,adoptViews:D,viewCovers:O,viewWrites:ee}=rr(),k=(0,v.useRef)(null),te=(0,v.useRef)(null),A=(0,v.useRef)(null),j=or({sidebarRef:k,sidebarWidth:a,resizeSidebar:o,commitSidebarWidth:s,resetSidebarWidth:c,bumpSidebarWrites:l}),ne=sr({upperRef:te,lowerRef:A,upperPct:f,resizeUpperPct:p,commitUpperPct:m,resetUpperPct:h,bumpUpperPctWrites:g});return{accent:e,next:t,cycle:n,upperPct:f,maximizedPanelOf:b,setMaximizedFor:x,viewOf:w,rememberedViewFor:T,rememberView:E,viewCovers:O,shell:{sidebarWidth:a,sidebarRef:k,upperRef:te,lowerRef:A,draggingSidebar:j.draggingSidebar,onSidebarDragStart:j.onSidebarDragStart,onSidebarDragMove:j.onSidebarDragMove,onSidebarDragEnd:j.onSidebarDragEnd,onSidebarDragCancel:j.onSidebarDragCancel,draggingUpper:ne.draggingUpper,onUpperDragStart:ne.onUpperDragStart,onUpperDragMove:ne.onUpperDragMove,onUpperDragEnd:ne.onUpperDragEnd,onUpperDragCancel:ne.onUpperDragCancel},guards:{adoptAccent:r,adoptSidebarWidth:u,adoptUpperPct:_,adoptMaximized:S,adoptViews:D,accentWrites:i,sidebarWrites:d,upperPctWrites:y,maximizedWrites:C,viewWrites:ee,draggingRef:j.draggingRef,upperDraggingRef:ne.upperDraggingRef}}}function lr(){let[e,t]=(0,v.useState)(null),[n,r]=(0,v.useState)(!1),i=(0,v.useCallback)(e=>{if(x(e)){t(!1);return}P.error(e instanceof Error?e.message:`request failed`)},[]),a=zn(),o=cr(),s=bn({authed:e,setAuthed:t,handle:i,resumeTick:a,...o.guards}),c=Rn({repo:s.repo,repos:s.repos,authed:e,hot:s.hot,clockSkewMs:s.clockSkewMs,resumeTick:a,handle:i,shell:o.shell,viewKnown:o.viewCovers(s.repo),rememberedView:o.viewOf(s.repo),latestView:o.rememberedViewFor,rememberView:o.rememberView,maximizedPanelOf:o.maximizedPanelOf,setMaximizedFor:o.setMaximizedFor}),{selectOpenedRepo:l,closeRepo:u}=Sn({repo:s.repo,repos:s.repos,setRepos:s.setRepos,setRepo:s.setRepo,setPane:c.setPane,setTab:c.setTab,setPickerOpen:r,handle:i,orderWrites:s.orderWrites}),{busy:d,start:f}=nn(l,e===!0),p=(0,v.useCallback)(e=>{e!==s.repo&&(s.setRepo(e),c.clearPane())},[s.repo,s.setRepo,c.clearPane]),m=(0,v.useCallback)(()=>r(!0),[]),h=(0,v.useCallback)(()=>r(!1),[]);return{authed:e,login:(0,v.useCallback)(()=>t(null),[]),reposLoaded:s.reposLoaded,rows:Qt(s.repo,c.maximized),upperPct:o.upperPct,header:{repos:s.repos,repo:s.repo,onSelectRepo:p,onCloseRepo:u,onOpenPicker:m,cloning:d,accent:o.accent,next:o.next,cycle:o.cycle,draggingRepo:s.draggingRepo,dragOverRepo:s.dragOverRepo,onRepoDragStart:s.onRepoDragStart,onRepoDragMove:s.onRepoDragMove,onRepoDragEnd:s.onRepoDragEnd},repoShell:c.repoShell,picker:n?{onClose:h,onOpened:l,canClone:s.canClone,cloning:d,onClone:f}:null}}function ur(){let e=lr();return e.authed===null?(0,L.jsx)(Ce,{}):e.authed?e.reposLoaded?(0,L.jsxs)(`div`,{className:`nc-fade grid h-full ${e.rows}`,style:{"--nc-upper":`${e.upperPct}fr`,"--nc-lower":`${100-e.upperPct}fr`},children:[(0,L.jsx)(Se,{...e.header}),e.repoShell?(0,L.jsx)(Zt,{...e.repoShell}):(0,L.jsx)(`div`,{className:`flex items-center justify-center p-6 text-center text-ink-400`,children:(0,L.jsxs)(`span`,{children:[`No repository open. Click`,` `,(0,L.jsx)(`span`,{className:`text-ink-200`,children:`+ open`}),` above to add one.`]})}),e.picker&&(0,L.jsx)(fe,{...e.picker})]}):(0,L.jsx)(Ce,{}):(0,L.jsx)(we,{onSuccess:e.login})}var dr={error:7e3,info:5e3,success:5e3},fr={error:`text-removed`,info:`text-accent`,success:`text-added`};function pr(){let[e,t]=(0,v.useState)([]);return(0,v.useEffect)(()=>N(t),[]),e.length===0?null:(0,L.jsx)(`div`,{className:`pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2`,"aria-live":`polite`,children:e.map(e=>(0,L.jsx)(mr,{toast:e},e.id))})}function mr({toast:e}){let[t,n]=(0,v.useState)(!1);return(0,v.useEffect)(()=>{if(t||e.sticky)return;let n=setTimeout(()=>ie(e.id),dr[e.kind]);return()=>clearTimeout(n)},[e.id,e.kind,e.bump,e.sticky,t]),(0,L.jsxs)(`div`,{role:e.kind===`error`?`alert`:`status`,className:`nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg`,onMouseEnter:()=>n(!0),onMouseLeave:()=>n(!1),children:[(0,L.jsx)(`span`,{className:`min-w-0 flex-1 break-words ${fr[e.kind]}`,children:e.message}),e.action&&(0,L.jsx)(`button`,{type:`button`,onClick:e.action.run,className:`shrink-0 rounded-sm border border-ink-700 px-1.5 py-0.5 text-ink-200 hover:border-accent hover:text-accent`,children:e.action.label}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>ie(e.id),"aria-label":`dismiss`,className:`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200`,children:(0,L.jsx)(se,{className:`h-3 w-3`})})]})}var hr=`--nc-visual-viewport-height`;function gr(e){if(!e||!Number.isFinite(e.height)||e.height<=0)return null;let t=Number.isFinite(e.offsetTop)&&e.offsetTop>0?e.offsetTop:0;return e.height+t}function _r(e,t){let n=t.visualViewport,r=()=>{let t=gr(n);t===null?e.style.removeProperty(hr):e.style.setProperty(hr,`${t}px`)};return r(),n?(n.addEventListener(`resize`,r),n.addEventListener(`scroll`,r),t.addEventListener(`resize`,r),()=>{n.removeEventListener(`resize`,r),n.removeEventListener(`scroll`,r),t.removeEventListener(`resize`,r),e.style.removeProperty(hr)}):()=>void 0}_r(document.documentElement,window),pn(document.querySelector(`meta[name="nightcrow-build"]`)?.getAttribute(`content`)||null),(0,y.createRoot)(document.getElementById(`root`)).render((0,L.jsxs)(v.StrictMode,{children:[(0,L.jsx)(Rt,{children:(0,L.jsx)(ur,{})}),(0,L.jsx)(pr,{})]}));export{Ct as a,ce as c,ie as d,P as f,l as g,s as h,Dt as i,se as l,o as m,on as n,wt as o,d as p,Ot as r,Tt as s,sn as t,I as u}; \ No newline at end of file diff --git a/viewer-ui/dist/assets/index-DoNXZFdA.js b/viewer-ui/dist/assets/index-DoNXZFdA.js new file mode 100644 index 00000000..a99a58e8 --- /dev/null +++ b/viewer-ui/dist/assets/index-DoNXZFdA.js @@ -0,0 +1,11 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-CDxnZumR.js","./Markdown-C8LL_u4z.css"])))=>i.map(i=>d[i]); +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ee(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var k=/\/+/g;function te(e,t){return typeof e==`object`&&e&&e.key!=null?ee(``+e.key):t.toString(36)}function ne(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function A(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,A(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+te(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(k,`$&/`)+`/`),A(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(k,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&te(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&te(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var ee=new MessageChannel,k=ee.port2;ee.port1.onmessage=D,O=function(){k.postMessage(null)}}else O=function(){_(D,0)};function te(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,te(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=p(),n=d(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1se||(e.current=oe[se],oe[se]=null,se--)}function F(e,t){se++,oe[se]=e.current,e.current=t}var I=N(null),ce=N(null),le=N(null),ue=N(null);function de(e,t){switch(F(le,t),F(ce,e),F(I,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}P(I),F(I,e)}function fe(){P(I),P(ce),P(le)}function pe(e){e.memoizedState!==null&&F(ue,e);var t=I.current,n=Hd(t,e.type);t!==n&&(F(ce,e),F(I,n))}function me(e){ce.current===e&&(P(I),P(ce)),ue.current===e&&(P(ue),Qf._currentValue=ae)}var he,ge;function _e(e){if(he===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);he=t&&t[1]||``,ge=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{ve=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?_e(n):``}function be(e,t){switch(e.tag){case 26:case 27:case 5:return _e(e.type);case 16:return _e(`Lazy`);case 13:return e.child!==t&&t!==null?_e(`Suspense Fallback`):_e(`Suspense`);case 19:return _e(`SuspenseList`);case 0:case 15:return ye(e.type,!1);case 11:return ye(e.type.render,!1);case 1:return ye(e.type,!0);case 31:return _e(`Activity`);default:return``}}function xe(e){try{var t=``,n=null;do t+=be(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Se=Object.prototype.hasOwnProperty,Ce=t.unstable_scheduleCallback,we=t.unstable_cancelCallback,Te=t.unstable_shouldYield,Ee=t.unstable_requestPaint,De=t.unstable_now,Oe=t.unstable_getCurrentPriorityLevel,ke=t.unstable_ImmediatePriority,Ae=t.unstable_UserBlockingPriority,je=t.unstable_NormalPriority,Me=t.unstable_LowPriority,Ne=t.unstable_IdlePriority,Pe=t.log,Fe=t.unstable_setDisableYieldValue,Ie=null,Le=null;function Re(e){if(typeof Pe==`function`&&Fe(e),Le&&typeof Le.setStrictMode==`function`)try{Le.setStrictMode(Ie,e)}catch{}}var ze=Math.clz32?Math.clz32:He,Be=Math.log,Ve=Math.LN2;function He(e){return e>>>=0,e===0?32:31-(Be(e)/Ve|0)|0}var Ue=256,We=262144,Ge=4194304;function Ke(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function qe(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ke(n))):i=Ke(o):i=Ke(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ke(n))):i=Ke(o)):i=Ke(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Je(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Ye(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Xe(){var e=Ge;return Ge<<=1,!(Ge&62914560)&&(Ge=4194304),e}function Ze(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Qe(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function $e(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),dn=!1;if(un)try{var fn={};Object.defineProperty(fn,"passive",{get:function(){dn=!0}}),window.addEventListener(`test`,fn,fn),window.removeEventListener(`test`,fn,fn)}catch{dn=!1}var pn=null,mn=null,hn=null;function gn(){if(hn)return hn;var e,t=mn,n=t.length,r,i=`value`in pn?pn.value:pn.textContent,a=i.length;for(e=0;e=Jn),Zn=` `,Qn=!1;function $n(e,t){switch(e){case`keyup`:return Kn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function er(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var tr=!1;function nr(e,t){switch(e){case`compositionend`:return er(t);case`keypress`:return t.which===32?(Qn=!0,Zn):null;case`textInput`:return e=t.data,e===Zn&&Qn?null:e;default:return null}}function rr(e,t){if(tr)return e===`compositionend`||!qn&&$n(e,t)?(e=gn(),hn=mn=pn=null,tr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Tr(n)}}function Dr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Dr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Or(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Lt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Lt(e.document)}return t}function kr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Ar=un&&`documentMode`in document&&11>=document.documentMode,jr=null,Mr=null,Nr=null,Pr=!1;function Fr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Pr||jr==null||jr!==Lt(r)||(r=jr,`selectionStart`in r&&kr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Nr&&wr(Nr,r)||(Nr=r,r=Ed(Mr,`onSelect`),0>=o,i-=o,Di=1<<32-ze(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),z&&ki(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),z&&ki(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return z&&ki(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),z&&ki(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&Oa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Fa(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=mi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=pi(o.type,o.key,o.props,null,e.mode,c),Fa(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=_i(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=Oa(o),b(e,r,o,c)}if(ie(o))return h(e,r,o,c);if(ne(o)){if(l=ne(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Pa(o),c);if(o.$$typeof===C)return b(e,r,na(e,o),c);Ia(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=hi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Na=0;var i=b(e,t,n,r);return Ma=null,i}catch(t){if(t===Sa||t===wa)throw t;var a=li(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ra=La(!0),za=La(!1),Ba=!1;function Va(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ha(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ua(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Wa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,G&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=oi(e),ai(e,null,n),t}return ni(e,r,t,n),oi(e)}function Ga(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tt(e,n)}}function Ka(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var qa=!1;function Ja(){if(qa){var e=pa;if(e!==null)throw e}}function Ya(e,t,n,r){qa=!1;var i=e.updateQueue;Ba=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(J&f)===f:(r&f)===f){f!==0&&f===fa&&(qa=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=m({},d,f);break a;case 2:Ba=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Gl|=o,e.lanes=o,e.memoizedState=d}}function Xa(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Za(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=j.T,s={};j.T=s,Fs(e,!1,t,n);try{var c=i(),l=j.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ps(e,t,ga(c,r),pu(e)):Ps(e,t,r,pu(e))}catch(n){Ps(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{M.p=a,o!==null&&s.types!==null&&(o.types=s.types),j.T=o}}function ws(){}function Ts(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=Es(e).queue;Cs(e,a,t,ae,n===null?ws:function(){return Ds(e),n(r)})}function Es(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ae,baseState:ae,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:ae},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Ds(e){var t=Es(e);t.next===null&&(t=e.alternate.memoizedState),Ps(e,t.next.queue,{},pu())}function Os(){return ta(Qf)}function ks(){return jo().memoizedState}function As(){return jo().memoizedState}function js(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Ua(n);var r=Wa(t,e,n);r!==null&&(hu(r,t,n),Ga(r,t,n)),t={cache:ca()},e.payload=t;return}t=t.return}}function Ms(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Is(e)?Ls(t,n):(n=ri(e,t,n,r),n!==null&&(hu(n,e,r),Rs(n,t,r)))}function Ns(e,t,n){Ps(e,t,n,pu())}function Ps(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Is(e))Ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Cr(s,o))return ni(e,t,i,0),K===null&&ti(),!1}catch{}if(n=ri(e,t,i,r),n!==null)return hu(n,e,r),Rs(n,t,r),!0}return!1}function Fs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Is(e)){if(t)throw Error(i(479))}else t=ri(e,n,r,2),t!==null&&hu(t,e,2)}function Is(e){var t=e.alternate;return e===B||t!==null&&t===B}function Ls(e,t){go=ho=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Rs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tt(e,n)}}var zs={readContext:ta,use:Po,useCallback:H,useContext:H,useEffect:H,useImperativeHandle:H,useLayoutEffect:H,useInsertionEffect:H,useMemo:H,useReducer:H,useRef:H,useState:H,useDebugValue:H,useDeferredValue:H,useTransition:H,useSyncExternalStore:H,useId:H,useHostTransitionStatus:H,useFormState:H,useActionState:H,useOptimistic:H,useMemoCache:H,useCacheRefresh:H};zs.useEffectEvent=H;var Bs={readContext:ta,use:Po,useCallback:function(e,t){return Ao().memoizedState=[e,t===void 0?null:t],e},useContext:ta,useEffect:us,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),cs(4194308,4,gs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return cs(4194308,4,e,t)},useInsertionEffect:function(e,t){cs(4,2,e,t)},useMemo:function(e,t){var n=Ao();t=t===void 0?null:t;var r=e();if(_o){Re(!0);try{e()}finally{Re(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Ao();if(n!==void 0){var i=n(t);if(_o){Re(!0);try{n(t)}finally{Re(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ms.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Ao();return e={current:e},t.memoizedState=e},useState:function(e){e=Ko(e);var t=e.queue,n=Ns.bind(null,B,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:vs,useDeferredValue:function(e,t){return xs(Ao(),e,t)},useTransition:function(){var e=Ko(!1);return e=Cs.bind(null,B,e.queue,!0,!1),Ao().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=B,a=Ao();if(z){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),K===null)throw Error(i(349));J&127||Vo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,us(Uo.bind(null,r,o,e),[e]),r.flags|=2048,os(9,{destroy:void 0},Ho.bind(null,r,o,n,t),null),n},useId:function(){var e=Ao(),t=K.identifierPrefix;if(z){var n=Oi,r=Di;n=(r&~(1<<32-ze(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=vo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[ct]=t,o[lt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Pc(t)}}return U(t),Fc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=le.current,Vi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Pi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ct]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Ri(t,!0)}else e=Bd(e).createTextNode(r),e[ct]=t,t.stateNode=e}return U(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Vi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[ct]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),e=!1}else n=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(lo(t),t):(lo(t),null);if(t.flags&128)throw Error(i(558))}return U(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Vi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[ct]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),a=!1}else a=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(lo(t),t):(lo(t),null)}return lo(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Lc(t,t.updateQueue),U(t),null);case 4:return fe(),e===null&&Sd(t.stateNode.containerInfo),U(t),null;case 10:return Yi(t.type),U(t),null;case 19:if(P(uo),r=t.memoizedState,r===null)return U(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Rc(r,!1);else{if(X!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=fo(e),o!==null){for(t.flags|=128,Rc(r,!1),e=o.updateQueue,t.updateQueue=e,Lc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)fi(n,e),n=n.sibling;return F(uo,uo.current&1|2),z&&ki(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&De()>tu&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304)}else{if(!a)if(e=fo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Lc(t,e),Rc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!z)return U(t),null}else 2*De()-r.renderingStartTime>tu&&n!==536870912&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(U(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=De(),e.sibling=null,n=uo.current,F(uo,a?n&1|2:n&1),z&&ki(t,r.treeForkCount),e);case 22:case 23:return lo(t),no(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(U(t),t.subtreeFlags&6&&(t.flags|=8192)):U(t),n=t.updateQueue,n!==null&&Lc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&P(va),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Yi(sa),U(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Bc(e,t){switch(Mi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Yi(sa),fe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return me(t),null;case 31:if(t.memoizedState!==null){if(lo(t),t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(lo(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return P(uo),null;case 4:return fe(),null;case 10:return Yi(t.type),null;case 22:case 23:return lo(t),no(),e!==null&&P(va),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Yi(sa),null;case 25:return null;default:return null}}function Vc(e,t){switch(Mi(t),t.tag){case 3:Yi(sa),fe();break;case 26:case 27:case 5:me(t);break;case 4:fe();break;case 31:t.memoizedState!==null&&lo(t);break;case 13:lo(t);break;case 19:P(uo);break;case 10:Yi(t.type);break;case 22:case 23:lo(t),no(),e!==null&&P(va);break;case 24:Yi(sa)}}function Hc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Uc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Wc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Za(t,n)}catch(t){Z(e,e.return,t)}}}function Gc(e,t,n){n.props=qs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Kc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function qc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Jc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Yc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[lt]=t}catch(t){Z(e,e.return,t)}}function Xc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Zc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Xc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=en));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Qc(e,t,n),e=e.sibling;e!==null;)Qc(e,t,n),e=e.sibling}function $c(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for($c(e,t,n),e=e.sibling;e!==null;)$c(e,t,n),e=e.sibling}function el(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[ct]=e,t[lt]=n}catch(t){Z(e,e.return,t)}}var tl=!1,nl=!1,rl=!1,il=typeof WeakSet==`function`?WeakSet:Set,al=null;function ol(e,t){if(e=e.containerInfo,Rd=sp,e=Or(e),kr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,al=t;al!==null;)if(t=al,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,al=e;else for(;al!==null;){switch(t=al,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[ct]=e,L(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Er(s,h),v=Er(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,j.T=null,n=lu,lu=null;var o=au,s=su;if(iu=0,ou=au=null,su=0,G&6)throw Error(i(331));var c=G;if(G|=4,Fl(o.current),Dl(o,o.current,s,n),G=c,id(0,!1),Le&&typeof Le.onPostCommitFiberRoot==`function`)try{Le.onPostCommitFiberRoot(Ie,o)}catch{}return!0}finally{M.p=a,j.T=r,Vu(e,t)}}function Wu(e,t,n){t=yi(n,t),t=$s(e.stateNode,t,2),e=Wa(e,t,2),e!==null&&(Qe(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(ru===null||!ru.has(r))){e=yi(n,e),n=ec(2),r=Wa(t,n,2),r!==null&&(tc(n,r,t,e),Qe(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new zl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ul=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,K===e&&(J&n)===n&&(X===4||X===3&&(J&62914560)===J&&300>De()-$l?!(G&2)&&Su(e,0):ql|=n,Yl===J&&(Yl=0)),rd(e)}function qu(e,t){t===0&&(t=Xe()),e=ii(e,t),e!==null&&(Qe(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Ce(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-ze(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=J,a=qe(r,r===K?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Je(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=De(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}iu!==0&&iu!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=zt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),L(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+zt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+zt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+zt(n.imageSizes)+`"]`)):i+=`[href="`+zt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=m({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),L(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+zt(r)+`"][href="`+zt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=m({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),L(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=bt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=m({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);L(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=bt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),L(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=bt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),L(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=le.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=bt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=bt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=bt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+zt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return m({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),L(t),e.head.appendChild(t))}function Pf(e){return`[src="`+zt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+zt(n.href)+`"]`);if(r)return t.instance=r,L(r),r;var a=m({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),L(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,L(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),L(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,L(a),a):(r=n,(a=mf.get(o))&&(r=m({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),L(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,L(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),L(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=g()})),v=d(),y=_(),b=class extends Error{status;constructor(e,t){super(t),this.status=e}},x=e=>e instanceof b&&e.status===401,S=class extends Error{constructor(e){super(`connection lost — check your network`,{cause:e}),this.name=`NetworkError`}},C=e=>e instanceof S;async function w(e,t){try{return await fetch(e,t)}catch(e){throw new S(e)}}async function T(e){if(!e.ok){let t=`request failed (${e.status})`;try{let n=await e.json();typeof n?.error==`string`&&(t=n.error)}catch{}throw new b(e.status,t)}let t=await e.json();if(t.version!==2)throw new b(e.status,`this page is out of date (server protocol v${t.version}) — reload`);return t}async function E(e,t){return T(await w(e,{credentials:`same-origin`,signal:t}))}async function D(e,t,n){return T(await w(e,{method:`POST`,credentials:`same-origin`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t),signal:n}))}var O=e=>new URLSearchParams(e).toString(),ee=1e4,k={async login(e){let t=await w(`/login`,{method:`POST`,credentials:`same-origin`,headers:{"Content-Type":`application/x-www-form-urlencoded`},body:new URLSearchParams({password:e}).toString()});if(!t.ok)throw new b(t.status,t.status===429?`too many attempts — wait a minute`:`incorrect password`)},repos:e=>E(`/api/repos`,e),setAccent:e=>D(`/api/prefs`,{accent:e}).then(e=>e.accent),setSidebarWidth:e=>D(`/api/prefs`,{sidebar_width:e}).then(e=>e.sidebar_width),setUpperPct:e=>D(`/api/prefs`,{upper_pct:e}).then(e=>e.upper_pct),setActiveRepo:e=>D(`/api/prefs`,{active_repo:e},AbortSignal.timeout(ee)).then(e=>e.active_repo),setMaximized:(e,t)=>D(`/api/prefs`,{maximized:{repo:e,panel:t}},AbortSignal.timeout(ee)).then(e=>e.maximized),setRepoView:(e,t)=>D(`/api/prefs`,{view:{repo:e,...t}},AbortSignal.timeout(ee)).then(e=>e.last_view),status:e=>E(`/api/status?${O({repo:e})}`),tree:(e,t)=>E(`/api/tree?${O({repo:e,path:t})}`),treeSearch:(e,t)=>E(`/api/tree/search?${O({repo:e,q:t})}`),previewUrl:(e,t,n)=>`/api/preview?${O(n?{repo:e,path:t,oid:n}:{repo:e,path:t})}`,log:(e,t)=>E(`/api/log?${O(t?{repo:e,from:t.from,skip:String(t.skip)}:{repo:e})}`),diff:(e,t)=>E(`/api/diff?${O({repo:e,path:t})}`),file:(e,t)=>E(`/api/file?${O({repo:e,path:t})}`),commit:(e,t)=>E(`/api/commit?${O({repo:e,oid:t})}`),commitFiles:(e,t)=>E(`/api/commit/files?${O({repo:e,oid:t})}`),commitFileDiff:(e,t,n)=>E(`/api/commit/file-diff?${O({repo:e,oid:t,path:n})}`),commitFile:(e,t,n)=>E(`/api/commit/file?${O({repo:e,oid:t,path:n})}`),browse:e=>E(`/api/browse${e?`?${O({path:e})}`:``}`),mkdir:(e,t)=>D(`/api/mkdir`,{path:e,name:t}).then(e=>e.path),clone:(e,t)=>D(`/api/clone`,{path:e,url:t}),cloneStatus:e=>E(`/api/clone?${O({job:String(e)})}`),runningClone:()=>E(`/api/clone`),open:e=>D(`/api/repos`,{path:e}).then(e=>e.repo),close:async e=>{let t=await w(`/api/repos?${O({repo:e})}`,{method:`DELETE`,credentials:`same-origin`});if(!t.ok)throw new b(t.status,`could not close (${t.status})`)},reorderRepos:e=>D(`/api/repos/order`,{order:e}).then(e=>e.repos),reloadConfig:()=>D(`/api/reload`,{}).then(e=>e.summary)};function te(e,t){let n=new EventSource(`/api/events?${O({repo:e})}`);return n.addEventListener(`status`,e=>{try{let n=JSON.parse(e.data);n.version===2&&t(n)}catch{}}),()=>n.close()}var ne=4,A=[],re=1,ie=new Set;function j(){let e=A;ie.forEach(t=>t(e))}function M(e){return ie.add(e),e(A),()=>{ie.delete(e)}}function ae(e){let t=A.filter(t=>t.id!==e);t.length!==A.length&&(A=t,j())}function oe(e,t,n={}){let r=A.findIndex(n=>n.kind===e&&n.message===t);if(r!==-1){let e=A[r];return A=A.map((e,t)=>t===r?{...e,...n,bump:e.bump+1}:e),j(),e.id}let i=re++;return A=se([...A,{id:i,kind:e,message:t,bump:0,...n}]),j(),i}function se(e){if(e.length<=ne)return e;let t=e[e.length-1],n=e.slice(0,-1);for(;n.length>=ne;){let e=n.findIndex(e=>!e.sticky);n.splice(e===-1?0:e,1)}return[...n,t]}var N={error:(e,t)=>oe(`error`,e,t),info:(e,t)=>oe(`info`,e,t),success:(e,t)=>oe(`success`,e,t)},P=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),F=o(((e,t)=>{t.exports=P()})),I=F();function ce({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M18 6 6 18`}),(0,I.jsx)(`path`,{d:`m6 6 12 12`})]})}function le({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M5 12h14`}),(0,I.jsx)(`path`,{d:`M12 5v14`})]})}function ue(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`circle`,{cx:`11`,cy:`11`,r:`8`}),(0,I.jsx)(`path`,{d:`m21 21-4.3-4.3`})]})}function de({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`}),(0,I.jsx)(`path`,{d:`m16 17 5-5-5-5`}),(0,I.jsx)(`path`,{d:`M21 12H9`})]})}function fe({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3H21`}),(0,I.jsx)(`path`,{d:`M21 3v6h-6`}),(0,I.jsx)(`path`,{d:`M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3H3`}),(0,I.jsx)(`path`,{d:`M3 21v-6h6`})]})}function pe({onClose:e,onOpened:t,canClone:n,cloning:r,onClone:i}){let[a,o]=(0,v.useState)(null),[s,c]=(0,v.useState)(null),[l,u]=(0,v.useState)(null),[d,f]=(0,v.useState)(!1),[p,m]=(0,v.useState)(``),[h,g]=(0,v.useState)(!1),[_,y]=(0,v.useState)(``),[b,x]=(0,v.useState)(0);(0,v.useEffect)(()=>{let e=!1;return k.browse(a??void 0).then(t=>{e||(c(t),u(null))}).catch(t=>{e||u(t instanceof Error?t.message:`could not browse`)}),()=>{e=!0}},[a,b]);let S=e=>o(`${s.path.replace(/\/$/,``)}/${e}`),C=async()=>{if(s){f(!0);try{t(await k.open(s.path))}catch(e){N.error(e instanceof Error?e.message:`could not open`),f(!1)}}},w=async()=>{if(!s)return;let e=p.trim();if(e){g(!0);try{await k.mkdir(s.path,e),m(``),x(e=>e+1)}catch(e){N.error(e instanceof Error?e.message:`could not create folder`)}finally{g(!1)}}};return(0,I.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4`,onClick:e,children:(0,I.jsxs)(`div`,{className:`flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900`,onClick:e=>e.stopPropagation(),children:[(0,I.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2`,children:[(0,I.jsx)(`span`,{className:`font-medium text-ink-50`,children:`Open a project`}),(0,I.jsx)(`button`,{onClick:e,"aria-label":`close`,className:`ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200`,children:(0,I.jsx)(ce,{})})]}),(0,I.jsx)(`div`,{className:`shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400`,children:s?.path??`…`}),(0,I.jsxs)(`ul`,{className:`h-72 min-h-0 overflow-y-auto`,children:[s?.parent&&(0,I.jsx)(`li`,{children:(0,I.jsx)(`button`,{onClick:()=>o(s.parent),className:`w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850`,children:`../`})}),s?.entries.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsxs)(`button`,{onClick:()=>S(e.name),className:`flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850`,children:[(0,I.jsxs)(`span`,{className:`truncate text-accent`,children:[e.name,`/`]}),e.is_repo&&(0,I.jsx)(`span`,{className:`rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200`,children:`git`})]})},e.name)),s&&s.entries.length===0&&(0,I.jsx)(`li`,{className:`px-3 py-1 text-ink-400`,children:`No sub-folders.`})]}),l&&(0,I.jsx)(`p`,{className:`shrink-0 px-3 py-1 text-removed`,children:l}),(0,I.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,I.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),onKeyDown:e=>{e.key===`Enter`&&w()},placeholder:`New folder name`,"aria-label":`new folder name`,className:`min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none`}),(0,I.jsx)(`button`,{onClick:w,disabled:!s||!p.trim()||h,className:`shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50`,children:h?`Creating…`:`Create`})]}),(0,I.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,I.jsx)(`input`,{value:_,onChange:e=>y(e.target.value),onKeyDown:e=>{e.key===`Enter`&&s&&i(s.path,_)},disabled:!n,placeholder:n?`Clone a git URL here`:`git is not installed on the server`,"aria-label":`git URL to clone`,spellCheck:!1,autoCapitalize:`none`,autoCorrect:`off`,className:`min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none disabled:opacity-50`}),(0,I.jsx)(`button`,{onClick:()=>s&&i(s.path,_),disabled:!n||!s||!_.trim()||r,title:n?void 0:`the server has no git on its PATH`,className:`shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50`,children:r?`Cloning…`:`Clone`})]}),(0,I.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2`,children:[(0,I.jsx)(`span`,{className:`truncate text-ink-400`,children:s?s.path:``}),(0,I.jsx)(`button`,{onClick:C,disabled:!s||d,className:`ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50`,children:d?`Opening…`:`Open`})]})]})})}var me=`data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='utf-8'?%3e%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='12%2057%201150%201150'%20role='img'%20aria-label='Black%20crow'%3e%3ctitle%3eBlack%20crow%3c/title%3e%3cdesc%3eMonochrome%20black%20crow%20silhouette%20on%20a%20transparent%20background,%20framed%20so%20the%20bird%20sits%20centred%20for%20use%20as%20an%20inline%20mark%20on%20a%20square%20tile.%3c/desc%3e%3cg%20fill-rule='evenodd'%20clip-rule='evenodd'%3e%3cpath%20fill='%23000000'%20d='M%20882%20147%20L%20859%20136%20L%20844%20131%20L%20831%20129%20L%20830%20128%20L%20815%20127%20L%20814%20126%20L%20796%20126%20L%20795%20127%20L%20786%20127%20L%20785%20128%20L%20775%20129%20L%20752%20136%20L%20732%20146%20L%20713%20160%20L%20701%20172%20L%20684%20172%20L%20683%20173%20L%20673%20173%20L%20672%20174%20L%20650%20176%20L%20649%20177%20L%20627%20181%20L%20602%20190%20L%20589%20197%20L%20579%20204%20L%20562%20221%20L%20562%20223%20L%20565%20223%20L%20578%20228%20L%20581%20228%20L%20612%20238%20L%20672%20252%20L%20684%20258%20L%20698%20271%20L%20702%20278%20L%20705%20288%20L%20705%20294%20L%20703%20301%20L%20699%20308%20L%20688%20318%20L%20630%20347%20L%20593%20372%20L%20561%20399%20L%20544%20416%20L%20522%20441%20L%20492%20481%20L%20461%20531%20L%20438%20576%20L%20431%20594%20L%20425%20602%20L%20405%20635%20L%20387%20668%20L%20385%20676%20L%20390%20679%20L%20368%20705%20L%20330%20755%20L%20306%20790%20L%20296%20808%20L%20289%20818%20L%20280%20838%20L%20280%20843%20L%20283%20845%20L%20292%20843%20L%20297%20840%20L%20299%20840%20L%20321%20828%20L%20322%20830%20L%20311%20844%20L%20288%20878%20L%20287%20881%20L%20259%20924%20L%20235%20965%20L%20205%201023%20L%20205%201025%20L%20197%201042%20L%20191%201061%20L%20191%201071%20L%20192%201072%20L%20198%201071%20L%20220%201056%20L%20242%201038%20L%20300%20986%20L%20302%20987%20L%20265%201040%20L%20264%201043%20L%20246%201070%20L%20235%201090%20L%20227%201112%20L%20227%201123%20L%20229%201128%20L%20234%201133%20L%20239%201135%20L%20255%201135%20L%20274%201129%20L%20279%201134%20L%20286%201137%20L%20290%201137%20L%20291%201138%20L%20310%201138%20L%20311%201137%20L%20317%201137%20L%20318%201136%20L%20326%201135%20L%20344%201129%20L%20369%201116%20L%20395%201097%20L%20420%201073%20L%20445%201042%20L%20457%201024%20L%20461%201016%20L%20464%201013%20L%20468%201011%20L%20489%20994%20L%20595%20901%20L%20601%20906%20L%20606%20913%20L%20614%20921%20L%20637%20949%20L%20639%20953%20L%20639%20956%20L%20636%20960%20L%20634%20961%20L%20619%20962%20L%20613%20965%20L%20605%20974%20L%20602%20982%20L%20602%20994%20L%20605%201001%20L%20608%201004%20L%20609%201004%20L%20609%20999%20L%20612%20992%20L%20616%20988%20L%20620%20986%20L%20627%20986%20L%20635%20983%20L%20645%20983%20L%20646%20982%20L%20655%20982%20L%20668%20986%20L%20676%20990%20L%20682%20996%20L%20685%201003%20L%20688%201006%20L%20696%201009%20L%20697%201012%20L%20697%201024%20L%20693%201033%20L%20693%201035%20L%20695%201035%20L%20700%201032%20L%20707%201025%20L%20710%201020%20L%20713%201010%20L%20713%201003%20L%20711%20998%20L%20711%20994%20L%20712%20993%20L%20719%201003%20L%20723%201005%20L%20727%201005%20L%20730%201011%20L%20730%201022%20L%20727%201031%20L%20728%201033%20L%20740%201021%20L%20743%201014%20L%20744%201003%20L%20747%20999%20L%20749%20992%20L%20748%20977%20L%20744%20968%20L%20740%20963%20L%20741%20962%20L%20755%20961%20L%20768%20964%20L%20777%20969%20L%20783%20975%20L%20786%20981%20L%20789%20984%20L%20795%20987%20L%20799%20987%20L%20801%20991%20L%20801%20997%20L%20802%20998%20L%20799%201013%20L%20802%201012%20L%20808%201007%20L%20813%201000%20L%20816%20991%20L%20816%20981%20L%20814%20976%20L%20814%20968%20L%20815%20967%20L%20819%20970%20L%20823%20970%20L%20826%20973%20L%20829%20980%20L%20830%20993%20L%20834%20990%20L%20838%20979%20L%20838%20968%20L%20832%20951%20L%20822%20940%20L%20815%20936%20L%20803%20933%20L%20776%20935%20L%20763%20931%20L%20753%20922%20L%20731%20898%20L%20703%20865%20L%20703%20863%20L%20710%20853%20L%20711%20855%20L%20707%20862%20L%20709%20862%20L%20718%20857%20L%20754%20832%20L%20793%20799%20L%20818%20774%20L%20849%20737%20L%20850%20741%20L%20845%20755%20L%20847%20755%20L%20861%20743%20L%20881%20721%20L%20906%20686%20L%20918%20665%20L%20933%20635%20L%20951%20590%20L%20971%20525%20L%20971%20521%20L%20974%20512%20L%20974%20508%20L%20978%20493%20L%20979%20482%20L%20980%20481%20L%20981%20466%20L%20982%20465%20L%20983%20441%20L%20982%20440%20L%20982%20428%20L%20981%20427%20L%20980%20414%20L%20978%20409%20L%20976%20397%20L%20970%20381%20L%20971%20379%20L%20974%20383%20L%20976%20381%20L%20977%20334%20L%20976%20333%20L%20976%20322%20L%20975%20321%20L%20974%20307%20L%20973%20306%20L%20973%20301%20L%20972%20300%20L%20969%20280%20L%20958%20243%20L%20949%20224%20L%20949%20222%20L%20939%20204%20L%20922%20181%20L%20903%20162%20Z%20M%20625%20888%20L%20656%20874%20L%20658%20874%20L%20665%20870%20L%20725%20930%20L%20728%20934%20L%20728%20940%20L%20723%20943%20L%20714%20944%20L%20707%20950%20L%20683%20951%20L%20673%20947%20L%20659%20932%20Z%20M%20787%20182%20L%20792%20182%20L%20796%20187%20L%20795%20192%20L%20791%20195%20L%20788%20195%20L%20783%20191%20L%20783%20186%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20895%20156%20L%20863%20138%20L%20838%20130%20L%20819%20127%20L%20789%20127%20L%20777%20129%20L%20753%20136%20L%20736%20144%20L%20717%20157%20L%20702%20172%20L%20652%20176%20L%20628%20181%20L%20607%20188%20L%20585%20200%20L%20562%20222%20L%20613%20238%20L%20670%20251%20L%20683%20257%20L%20697%20269%20L%20705%20286%20L%20705%20296%20L%20703%20302%20L%20698%20310%20L%20687%20319%20L%20631%20347%20L%20591%20374%20L%20568%20393%20L%20538%20423%20L%20520%20444%20L%20481%20498%20L%20457%20539%20L%20437%20579%20L%20432%20593%20L%20413%20622%20L%20386%20671%20L%20386%20677%20L%20390%20677%20L%20391%20679%20L%20374%20698%20L%20334%20750%20L%20307%20789%20L%20290%20817%20L%20280%20839%20L%20281%20844%20L%20291%20843%20L%20323%20826%20L%20325%20827%20L%20289%20877%20L%20237%20962%20L%20209%201015%20L%20198%201040%20L%20191%201063%20L%20191%201070%20L%20194%201072%20L%20213%201061%20L%20259%201023%20L%20302%20984%20L%20303%20985%20L%20262%201045%20L%20245%201072%20L%20233%201095%20L%20227%201114%20L%20228%201126%20L%20233%201132%20L%20242%201135%20L%20252%201135%20L%20274%201128%20L%20278%201133%20L%20288%201137%20L%20313%201137%20L%20343%201129%20L%20373%201113%20L%20398%201094%20L%20424%201068%20L%20441%201047%20L%20465%201012%20L%20520%20967%20L%20595%20900%20L%20611%20917%20L%20639%20952%20L%20639%20957%20L%20637%20960%20L%20632%20962%20L%20618%20963%20L%20611%20967%20L%20606%20973%20L%20602%20983%20L%20602%20992%20L%20608%201004%20L%20611%20993%20L%20619%20986%20L%20626%20986%20L%20643%20982%20L%20656%20982%20L%20675%20989%20L%20683%20997%20L%20688%201006%20L%20696%201009%20L%20697%201025%20L%20693%201034%20L%20694%201035%20L%20701%201031%20L%20710%201019%20L%20712%201013%20L%20712%20993%20L%20720%201003%20L%20727%201005%20L%20730%201009%20L%20730%201025%20L%20727%201032%20L%20732%201030%20L%20739%201022%20L%20743%201013%20L%20743%201004%20L%20749%20991%20L%20748%20978%20L%20740%20964%20L%20743%20961%20L%20757%20961%20L%20769%20964%20L%20781%20972%20L%20791%20985%20L%20798%20986%20L%20802%20994%20L%20802%201003%20L%20799%201013%20L%20810%201004%20L%20815%20994%20L%20816%20983%20L%20814%20977%20L%20814%20965%20L%20818%20969%20L%20825%20971%20L%20830%20983%20L%20830%20993%20L%20833%20991%20L%20837%20982%20L%20838%20969%20L%20834%20956%20L%20825%20943%20L%20820%20939%20L%20807%20934%20L%20774%20935%20L%20762%20931%20L%20736%20904%20L%20702%20864%20L%20715%20845%20L%20716%20847%20L%20707%20862%20L%20719%20856%20L%20744%20839%20L%20786%20805%20L%20824%20767%20L%20851%20733%20L%20852%20735%20L%20846%20755%20L%20853%20750%20L%20883%20718%20L%20906%20685%20L%20927%20647%20L%20951%20589%20L%20968%20535%20L%20976%20502%20L%20982%20461%20L%20981%20422%20L%20976%20398%20L%20968%20378%20L%20969%20376%20L%20975%20383%20L%20977%20341%20L%20970%20286%20L%20958%20244%20L%20945%20215%20L%20925%20185%20L%20906%20165%20Z%20M%20625%20888%20L%20665%20870%20L%20728%20933%20L%20729%20940%20L%20726%20943%20L%20715%20944%20L%20708%20950%20L%20692%20952%20L%20678%20950%20L%20672%20947%20L%20662%20936%20Z%20M%20785%20183%20L%20792%20182%20L%20796%20186%20L%20796%20191%20L%20791%20195%20L%20785%20194%20L%20783%20191%20L%20783%20186%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20896%20157%20L%20869%20141%20L%20837%20130%20L%20817%20127%20L%20792%20127%20L%20778%20129%20L%20751%20137%20L%20733%20146%20L%20715%20159%20L%20702%20172%20L%20653%20176%20L%20610%20187%20L%20581%20203%20L%20562%20222%20L%20625%20241%20L%20671%20251%20L%20683%20257%20L%20698%20270%20L%20705%20285%20L%20704%20300%20L%20699%20309%20L%20689%20318%20L%20628%20349%20L%20581%20382%20L%20540%20421%20L%20517%20448%20L%20478%20503%20L%20456%20541%20L%20439%20575%20L%20432%20593%20L%20412%20624%20L%20388%20667%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20363%20712%20L%20332%20753%20L%20289%20819%20L%20281%20836%20L%20281%20844%20L%20293%20842%20L%20323%20826%20L%20325%20827%20L%20284%20885%20L%20236%20964%20L%20205%201024%20L%20197%201043%20L%20191%201064%20L%20191%201070%20L%20197%201071%20L%20210%201063%20L%20253%201028%20L%20303%20983%20L%20304%20984%20L%20259%201050%20L%20235%201091%20L%20227%201115%20L%20228%201125%20L%20232%201131%20L%20238%201134%20L%20249%201135%20L%20261%201133%20L%20274%201128%20L%20278%201133%20L%20289%201137%20L%20312%201137%20L%20342%201129%20L%20368%201116%20L%20399%201093%20L%20423%201069%20L%20443%201044%20L%20463%201013%20L%20493%20990%20L%20595%20900%20L%20612%20918%20L%20639%20952%20L%20639%20957%20L%20636%20961%20L%20616%20964%20L%20606%20973%20L%20602%20984%20L%20602%20991%20L%20608%201004%20L%20611%20993%20L%20621%20985%20L%20625%20986%20L%20639%20982%20L%20657%20982%20L%20677%20990%20L%20685%201002%20L%20690%201007%20L%20697%201010%20L%20698%201021%20L%20693%201034%20L%20694%201035%20L%20705%201027%20L%20710%201018%20L%20712%201011%20L%20712%201001%20L%20710%20994%20L%20712%20993%20L%20720%201003%20L%20729%201006%20L%20731%201020%20L%20728%201032%20L%20738%201023%20L%20743%201012%20L%20743%201003%20L%20748%20994%20L%20747%20976%20L%20740%20964%20L%20743%20961%20L%20759%20961%20L%20767%20963%20L%20780%20971%20L%20790%20984%20L%20799%20986%20L%20802%20992%20L%20802%201004%20L%20799%201012%20L%20803%201011%20L%20810%201004%20L%20815%20994%20L%20814%20965%20L%20818%20969%20L%20823%20969%20L%20830%20982%20L%20830%20992%20L%20832%20992%20L%20837%20982%20L%20837%20965%20L%20831%20950%20L%20821%20940%20L%20806%20934%20L%20772%20935%20L%20762%20931%20L%20733%20901%20L%20702%20863%20L%20717%20842%20L%20718%20844%20L%20707%20862%20L%20738%20843%20L%20780%20810%20L%20822%20769%20L%20851%20733%20L%20852%20735%20L%20846%20755%20L%20855%20748%20L%20882%20719%20L%20904%20688%20L%20930%20640%20L%20950%20591%20L%20967%20538%20L%20978%20490%20L%20982%20459%20L%20982%20434%20L%20976%20399%20L%20967%20376%20L%20969%20375%20L%20975%20383%20L%20976%20329%20L%20971%20293%20L%20960%20250%20L%20942%20210%20L%20923%20183%20Z%20M%20625%20888%20L%20665%20870%20L%20729%20934%20L%20729%20940%20L%20726%20943%20L%20716%20944%20L%20708%20950%20L%20695%20952%20L%20677%20950%20L%20666%20941%20Z%20M%20786%20182%20L%20790%20181%20L%20794%20183%20L%20797%20188%20L%20792%20195%20L%20787%20195%20L%20782%20190%20L%20782%20187%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20893%20155%20L%20873%20143%20L%20841%20131%20L%20816%20127%20L%20794%20127%20L%20779%20129%20L%20754%20136%20L%20735%20145%20L%20715%20159%20L%20702%20172%20L%20648%20177%20L%20629%20181%20L%20608%20188%20L%20584%20201%20L%20562%20222%20L%20614%20238%20L%20671%20251%20L%20685%20258%20L%20698%20270%20L%20704%20281%20L%20706%20292%20L%20703%20303%20L%20699%20309%20L%20686%20320%20L%20628%20349%20L%20590%20375%20L%20565%20396%20L%20543%20418%20L%20518%20447%20L%20490%20485%20L%20460%20534%20L%20441%20571%20L%20431%20595%20L%20405%20636%20L%20386%20672%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20371%20702%20L%20335%20749%20L%20291%20816%20L%20280%20840%20L%20282%20844%20L%20298%20840%20L%20324%20825%20L%20326%20826%20L%20294%20870%20L%20235%20966%20L%20206%201022%20L%20198%201041%20L%20191%201065%20L%20191%201070%20L%20196%201071%20L%20214%201060%20L%20254%201027%20L%20303%20983%20L%20304%20984%20L%20256%201055%20L%20232%201098%20L%20227%201115%20L%20228%201125%20L%20232%201131%20L%20239%201134%20L%20248%201135%20L%20264%201132%20L%20274%201128%20L%20280%201134%20L%20289%201137%20L%20311%201137%20L%20339%201130%20L%20371%201114%20L%20401%201091%20L%20422%201070%20L%20440%201048%20L%20463%201013%20L%20487%20995%20L%20595%20900%20L%20613%20919%20L%20639%20951%20L%20639%20958%20L%20636%20961%20L%20619%20963%20L%20613%20966%20L%20605%20975%20L%20602%20991%20L%20604%20998%20L%20608%201003%20L%20611%20993%20L%20620%20985%20L%20624%20986%20L%20632%20983%20L%20647%20981%20L%20658%20982%20L%20677%20990%20L%20689%201006%20L%20697%201009%20L%20698%201021%20L%20694%201035%20L%20703%201029%20L%20710%201018%20L%20712%201010%20L%20712%201002%20L%20710%20997%20L%20711%20992%20L%20720%201003%20L%20728%201005%20L%20730%201008%20L%20731%201021%20L%20728%201032%20L%20738%201023%20L%20742%201014%20L%20743%201003%20L%20748%20994%20L%20748%20980%20L%20742%20966%20L%20739%20963%20L%20741%20961%20L%20753%20960%20L%20770%20964%20L%20780%20971%20L%20790%20984%20L%20799%20986%20L%20802%20992%20L%20802%201005%20L%20799%201012%20L%20804%201010%20L%20809%201005%20L%20815%20993%20L%20813%20964%20L%20817%20968%20L%20823%20969%20L%20826%20972%20L%20830%20982%20L%20831%20992%20L%20834%20989%20L%20838%20975%20L%20837%20966%20L%20831%20950%20L%20821%20940%20L%20804%20934%20L%20779%20936%20L%20763%20932%20L%20731%20899%20L%20702%20865%20L%20702%20863%20L%20717%20842%20L%20718%20844%20L%20708%20862%20L%20749%20835%20L%20781%20809%20L%20820%20771%20L%20851%20733%20L%20852%20736%20L%20846%20755%20L%20857%20746%20L%20881%20720%20L%20907%20683%20L%20928%20644%20L%20945%20604%20L%20967%20537%20L%20978%20489%20L%20982%20455%20L%20982%20437%20L%20979%20413%20L%20975%20396%20L%20967%20377%20L%20968%20374%20L%20975%20382%20L%20976%20332%20L%20972%20299%20L%20962%20257%20L%20954%20235%20L%20943%20212%20L%20923%20183%20Z%20M%20624%20888%20L%20666%20870%20L%20729%20934%20L%20729%20940%20L%20726%20943%20L%20716%20944%20L%20706%20951%20L%20684%20952%20L%20677%20950%20L%20665%20940%20Z%20M%20701%20220%20L%20710%20219%20L%20717%20221%20L%20704%20223%20L%20704%20221%20Z%20M%20666%20217%20L%20679%20216%20L%20689%20218%20L%20685%20220%20L%20675%20220%20Z%20M%20658%20210%20L%20661%20208%20L%20686%20205%20L%20706%20206%20L%20725%20209%20L%20733%20217%20L%20741%20220%20L%20738%20221%20L%20696%20214%20L%20661%20212%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20188%20L%20792%20195%20L%20786%20195%20L%20782%20190%20L%20783%20185%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20893%20155%20L%20864%20139%20L%20836%20130%20L%20813%20127%20L%20796%20127%20L%20774%20130%20L%20749%20138%20L%20735%20145%20L%20720%20155%20L%20702%20172%20L%20687%20173%20L%20700%20174%20L%20694%20181%20L%20674%20184%20L%20670%20180%20L%20665%20183%20L%20657%20184%20L%20639%20196%20L%20641%20198%20L%20647%20198%20L%20648%20194%20L%20654%20193%20L%20686%20204%20L%20724%20208%20L%20740%20213%20L%20751%20222%20L%20750%20223%20L%20693%20214%20L%20671%20212%20L%20639%20212%20L%20636%20211%20L%20634%20207%20L%20629%20207%20L%20626%20210%20L%20617%20210%20L%20614%20208%20L%20604%20207%20L%20594%20213%20L%20582%20216%20L%20580%20214%20L%20581%20210%20L%20576%20208%20L%20586%20200%20L%20565%20218%20L%20563%20222%20L%20618%20239%20L%20671%20251%20L%20682%20256%20L%20689%20261%20L%20702%20276%20L%20706%20288%20L%20704%20301%20L%20700%20308%20L%20683%20322%20L%20630%20348%20L%20585%20379%20L%20563%20398%20L%20523%20441%20L%20497%20475%20L%20459%20536%20L%20443%20567%20L%20431%20595%20L%20411%20626%20L%20387%20670%20L%20386%20676%20L%20390%20677%20L%20391%20679%20L%20368%20706%20L%20333%20752%20L%20290%20818%20L%20281%20837%20L%20282%20844%20L%20300%20839%20L%20324%20825%20L%20326%20826%20L%20285%20884%20L%20242%20954%20L%20207%201020%20L%20192%201060%20L%20192%201071%20L%20196%201071%20L%20211%201062%20L%20240%201039%20L%20303%20983%20L%20305%20984%20L%20277%201023%20L%20242%201078%20L%20228%201110%20L%20227%201119%20L%20231%201130%20L%20239%201134%20L%20255%201134%20L%20274%201128%20L%20280%201134%20L%20285%201136%20L%20310%201137%20L%20339%201130%20L%20374%201112%20L%20394%201097%20L%20421%201071%20L%20445%201041%20L%20463%201013%20L%20488%20994%20L%20595%20900%20L%20614%20920%20L%20639%20951%20L%20639%20958%20L%20636%20961%20L%20619%20963%20L%20613%20966%20L%20607%20972%20L%20603%20980%20L%20602%20989%20L%20603%20995%20L%20608%201003%20L%20610%20994%20L%20620%20985%20L%20623%20986%20L%20631%20983%20L%20647%20981%20L%20665%20984%20L%20676%20989%20L%20689%201006%20L%20695%201007%20L%20697%201009%20L%20698%201023%20L%20694%201035%20L%20699%201032%20L%20709%201020%20L%20712%201010%20L%20710%20997%20L%20711%20992%20L%20719%201002%20L%20725%201005%20L%20727%201004%20L%20730%201008%20L%20731%201022%20L%20728%201032%20L%20739%201021%20L%20743%201010%20L%20743%201003%20L%20748%20993%20L%20748%20981%20L%20746%20974%20L%20739%20963%20L%20741%20961%20L%20755%20960%20L%20770%20964%20L%20779%20970%20L%20789%20983%20L%20799%20986%20L%20802%20992%20L%20802%201005%20L%20799%201012%20L%20808%201006%20L%20815%20993%20L%20813%20964%20L%20817%20968%20L%20825%20970%20L%20829%20978%20L%20831%20992%20L%20837%20981%20L%20837%20966%20L%20832%20952%20L%20822%20941%20L%20813%20936%20L%20803%20934%20L%20776%20936%20L%20763%20932%20L%20723%20890%20L%20702%20865%20L%20702%20863%20L%20717%20841%20L%20719%20842%20L%20708%20862%20L%20742%20840%20L%20782%20808%20L%20818%20773%20L%20851%20733%20L%20852%20736%20L%20846%20754%20L%20850%20752%20L%20881%20720%20L%20905%20686%20L%20932%20635%20L%20950%20590%20L%20969%20529%20L%20977%20494%20L%20982%20454%20L%20980%20419%20L%20975%20396%20L%20967%20377%20L%20968%20374%20L%20975%20382%20L%20976%20334%20L%20969%20284%20L%20959%20248%20L%20941%20209%20L%20921%20181%20Z%20M%20624%20888%20L%20666%20870%20L%20729%20934%20L%20729%20941%20L%20724%20944%20L%20716%20944%20L%20707%20951%20L%20683%20952%20L%20673%20948%20L%20659%20933%20Z%20M%20563%20618%20L%20569%20614%20L%20579%20621%20L%20576%20626%20L%20572%20627%20L%20568%20623%20L%20565%20623%20Z%20M%20575%20603%20L%20578%20603%20L%20588%20613%20L%20588%20619%20L%20585%20621%20L%20582%20620%20L%20575%20613%20L%20576%20612%20L%20573%20605%20Z%20M%20748%20398%20L%20752%20412%20L%20752%20432%20L%20748%20444%20L%20732%20470%20L%20716%20484%20L%20698%20493%20L%20685%20494%20L%20682%20491%20L%20707%20445%20L%20685%20473%20L%20667%20492%20L%20647%20508%20L%20632%20517%20L%20619%20519%20L%20615%20517%20L%20615%20513%20L%20643%20470%20L%20603%20514%20L%20589%20526%20L%20569%20538%20L%20558%20540%20L%20554%20539%20L%20552%20535%20L%20577%20498%20L%20549%20528%20L%20526%20546%20L%20508%20554%20L%20498%20555%20L%20495%20552%20L%20501%20541%20L%20482%20555%20L%20471%20558%20L%20464%20558%20L%20461%20560%20L%20460%20559%20L%20461%20555%20L%20469%20547%20L%20473%20537%20L%20491%20512%20L%20511%20497%20L%20582%20434%20L%20617%20408%20L%20632%20399%20L%20659%20386%20L%20677%20380%20L%20695%20377%20L%20712%20377%20L%20725%20380%20L%20739%20388%20Z%20M%20625%20216%20L%20683%20216%20L%20737%20222%20L%20742%20224%20L%20733%20226%20L%20721%20225%20L%20713%20230%20L%20711%20228%20L%20706%20228%20L%20694%20237%20L%20690%20233%20L%20690%20225%20L%20679%20228%20L%20674%20232%20L%20668%20232%20L%20659%20229%20L%20649%20222%20L%20632%20219%20L%20632%20217%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20187%20L%20795%20193%20L%20792%20195%20L%20786%20195%20L%20782%20190%20L%20783%20185%20Z'/%3e%3cpath%20fill='%23000000'%20d='M%20607%20972%20L%20603%20981%20L%20603%20995%20L%20608%201003%20L%20610%20994%20L%20616%20987%20L%20620%20985%20L%20627%20985%20L%20635%20982%20L%20655%20981%20L%20676%20989%20L%20683%20996%20L%20689%201006%20L%20695%201007%20L%20697%201009%20L%20698%201024%20L%20694%201034%20L%20699%201032%20L%20706%201025%20L%20712%201010%20L%20710%20994%20L%20709%20997%20L%20706%20994%20L%20700%20994%20L%20694%20997%20L%20672%20976%20L%20658%20974%20L%20647%20967%20L%20642%20974%20L%20638%20974%20L%20636%20972%20L%20636%20961%20L%20629%20963%20L%20631%20972%20L%20625%20977%20L%20620%20977%20L%20613%20967%20L%20614%20966%20Z%20M%20686%20966%20L%20704%20980%20L%20719%201002%20L%20727%201004%20L%20730%201007%20L%20731%201023%20L%20728%201032%20L%20731%201030%20L%20741%201017%20L%20743%201003%20L%20748%20992%20L%20748%20981%20L%20742%20967%20L%20740%20965%20L%20742%20968%20L%20735%20972%20L%20726%20965%20L%20705%20960%20L%20724%20968%20L%20740%20983%20L%20742%20987%20L%20740%20991%20L%20732%20991%20L%20728%20996%20L%20726%20996%20L%20721%20992%20L%20713%20979%20Z%20M%20784%20943%20L%20784%20946%20L%20798%20950%20L%20819%20969%20L%20825%20970%20L%20830%20981%20L%20831%20992%20L%20837%20981%20L%20837%20966%20L%20829%20948%20L%20825%20944%20L%20830%20950%20L%20821%20957%20L%20813%20951%20L%20794%20942%20Z%20M%20659%20933%20L%20662%20937%20L%20658%20942%20L%20658%20945%20L%20663%20947%20L%20666%20945%20L%20667%20946%20L%20668%20944%20L%20670%20946%20Z%20M%20643%20913%20L%20647%20918%20L%20644%20921%20L%20642%20928%20L%20629%20938%20L%20627%20936%20L%20636%20947%20L%20634%20945%20L%20641%20933%20L%20651%20923%20L%20657%20930%20Z%20M%20936%20310%20L%20934%20309%20L%20945%20338%20L%20949%20368%20L%20949%20381%20L%20947%20383%20L%20935%20367%20L%20914%20348%20L%20929%20377%20L%20934%20397%20L%20936%20414%20L%20935%20440%20L%20933%20442%20L%20930%20440%20L%20924%20419%20L%20912%20395%20L%20909%20393%20L%20910%20410%20L%20907%20433%20L%20899%20458%20L%20895%20462%20L%20893%20460%20L%20892%20445%20L%20888%20427%20L%20873%20390%20L%20873%20416%20L%20871%20430%20L%20866%20448%20L%20862%20454%20L%20859%20452%20L%20853%20433%20L%20842%20410%20L%20823%20382%20L%20807%20365%20L%20805%20366%20L%20811%20394%20L%20812%20416%20L%20810%20425%20L%20806%20428%20L%20801%20423%20L%20790%20403%20L%20772%20383%20L%20759%20372%20L%20733%20357%20L%20724%20355%20L%20705%20346%20L%20693%20344%20L%20669%20344%20L%20646%20349%20L%20636%20353%20L%20662%20349%20L%20677%20349%20L%20699%20353%20L%20708%20356%20L%20725%20366%20L%20736%20376%20L%20745%20389%20L%20753%20415%20L%20752%20442%20L%20743%20476%20L%20723%20520%20L%20693%20569%20L%20646%20631%20L%20605%20676%20L%20568%20709%20L%20565%20710%20L%20562%20706%20L%20562%20692%20L%20567%20665%20L%20578%20636%20L%20602%20619%20L%20622%20602%20L%20645%20578%20L%20670%20546%20L%20630%20588%20L%20603%20610%20L%20574%20628%20L%20560%20633%20L%20556%20633%20L%20555%20631%20L%20563%20617%20L%20596%20572%20L%20647%20509%20L%20636%20515%20L%20562%20609%20L%20541%20630%20L%20520%20645%20L%20502%20653%20L%20490%20653%20L%20536%20590%20L%20513%20617%20L%20478%20651%20L%20454%20665%20L%20440%20669%20L%20436%20667%20L%20488%20596%20L%20459%20630%20L%20436%20653%20L%20411%20671%20L%20392%20678%20L%20372%20701%20L%20319%20772%20L%20289%20820%20L%20281%20838%20L%20282%20844%20L%20295%20841%20L%20324%20825%20L%20326%20826%20L%20289%20878%20L%20242%20954%20L%20209%201016%20L%20193%201056%20L%20191%201069%20L%20192%201071%20L%20196%201071%20L%20219%201056%20L%20303%20983%20L%20305%20984%20L%20255%201057%20L%20234%201094%20L%20228%201111%20L%20228%201124%20L%20234%201132%20L%20240%201134%20L%20254%201134%20L%20275%201128%20L%20279%201133%20L%20291%201137%20L%20316%201136%20L%20344%201128%20L%20364%201118%20L%20389%201101%20L%20403%201089%20L%20427%201064%20L%20447%201038%20L%20463%201013%20L%20489%20993%20L%20594%20901%20L%20595%20899%20L%20593%20894%20L%20588%20891%20L%20567%20868%20L%20569%20865%20L%20576%20867%20L%20586%20873%20L%20589%20867%20L%20607%20867%20L%20619%20880%20L%20622%20887%20L%20626%20891%20L%20624%20889%20L%20626%20886%20L%20666%20870%20L%20705%20909%20L%20710%20905%20L%20713%20905%20L%20716%20908%20L%20716%20911%20L%20712%20916%20L%20728%20932%20L%20730%20939%20L%20727%20943%20L%20717%20944%20L%20710%20949%20L%20712%20948%20L%20725%20951%20L%20739%20963%20L%20740%20961%20L%20756%20960%20L%20774%20966%20L%20784%20975%20L%20789%20983%20L%20800%20987%20L%20802%20991%20L%20802%201005%20L%20799%201012%20L%20806%201008%20L%20811%201002%20L%20815%20993%20L%20813%20968%20L%20812%20973%20L%20807%20971%20L%20796%20976%20L%20791%20973%20L%20788%20967%20L%20779%20958%20L%20767%20952%20L%20762%20941%20L%20756%20940%20L%20751%20932%20L%20749%20931%20L%20743%20936%20L%20738%20936%20L%20736%20933%20L%20741%20925%20L%20739%20915%20L%20742%20912%20L%20745%20914%20L%20702%20865%20L%20702%20863%20L%20717%20841%20L%20719%20842%20L%20709%20861%20L%20746%20837%20L%20783%20807%20L%20826%20764%20L%20851%20732%20L%20853%20734%20L%20846%20754%20L%20851%20751%20L%20874%20728%20L%20890%20708%20L%20908%20681%20L%20938%20621%20L%20960%20560%20L%20971%20521%20L%20979%20481%20L%20982%20444%20L%20977%20405%20L%20970%20382%20L%20966%20375%20L%20968%20374%20L%20974%20381%20L%20975%20368%20L%20974%20372%20L%20968%20364%20L%20966%20354%20L%20957%20337%20Z%20M%20247%201077%20L%20251%201085%20L%20246%201088%20L%20245%201091%20L%20240%201092%20L%20239%201085%20L%20241%201081%20Z%20M%20370%20935%20L%20371%20937%20L%20326%20997%20L%20301%201034%20L%20289%201044%20L%20286%201042%20L%20283%201045%20L%20277%201045%20L%20273%201043%20L%20264%201052%20L%20261%201049%20L%20270%201037%20L%20271%201038%20L%20281%201027%20L%20293%201010%20L%20326%20971%20Z%20M%20726%20921%20L%20728%20921%20L%20731%20926%20L%20729%20933%20L%20722%20926%20Z%20M%20729%20897%20L%20734%20902%20L%20732%20906%20L%20726%20902%20L%20726%20899%20Z%20M%20715%20882%20L%20720%20886%20L%20718%20890%20L%20711%20887%20Z%20M%20605%20762%20L%20608%20761%20L%20607%20760%20L%20609%20757%20L%20610%20759%20L%20617%20761%20L%20620%20764%20L%20611%20770%20L%20607%20770%20L%20606%20765%20L%20608%20765%20Z%20M%20611%20754%20L%20617%20750%20L%20620%20751%20L%20621%20749%20L%20625%20749%20L%20628%20752%20L%20628%20756%20L%20623%20761%20Z%20M%20549%20710%20L%20551%20713%20L%20551%20722%20L%20506%20774%20L%20403%20878%20L%20309%20963%20L%20307%20961%20L%20313%20953%20L%20322%20945%20L%20340%20922%20L%20375%20885%20Z%20M%20695%20704%20L%20696%20708%20L%20698%20709%20L%20695%20710%20L%20695%20713%20L%20692%20715%20L%20688%20710%20Z%20M%20704%20694%20L%20706%20695%20L%20706%20698%20L%20710%20698%20L%20713%20703%20L%20708%20708%20L%20707%20714%20L%20705%20716%20L%20701%20716%20L%20697%20708%20L%20699%20706%20L%20697%20705%20L%20701%20701%20L%20704%20705%20L%20706%20704%20L%20706%20700%20L%20702%20697%20Z%20M%20552%20678%20L%20554%20681%20L%20552%20701%20L%20465%20787%20L%20361%20883%20L%20316%20927%20L%20253%20993%20L%20211%201042%20L%20211%201036%20L%20222%201011%20L%20261%20942%20L%20292%20898%20L%20338%20843%20L%20374%20807%20L%20387%20807%20L%20403%20801%20L%20419%20792%20L%20456%20766%20L%20500%20728%20Z%20M%20704%20654%20L%20706%20658%20L%20711%20658%20L%20714%20661%20L%20715%20673%20L%20713%20676%20L%20705%20676%20L%20702%20674%20L%20704%20677%20L%20704%20681%20L%20702%20682%20L%20704%20685%20L%20700%20686%20L%20694%20678%20L%20691%20678%20L%20688%20674%20L%20692%20670%20L%20692%20664%20L%20694%20661%20Z%20M%20565%20642%20L%20558%20663%20L%20545%20677%20L%20494%20726%20L%20443%20767%20L%20401%20792%20L%20392%20795%20L%20387%20794%20L%20405%20766%20L%20455%20707%20L%20388%20767%20L%20355%20794%20L%20304%20827%20L%20299%20826%20L%20302%20817%20L%20316%20795%20L%20360%20739%20L%20398%20700%20L%20425%20677%20L%20428%20679%20L%20443%20679%20L%20461%20673%20L%20476%20664%20L%20493%20666%20L%20517%20657%20L%20542%20641%20L%20546%20643%20Z%20M%20563%20221%20L%20572%20225%20L%20571%20223%20L%20574%20220%20L%20585%20217%20L%20596%20218%20L%20607%20215%20L%20653%20214%20L%20706%20218%20L%20749%20224%20L%20749%20226%20L%20735%20233%20L%20732%20238%20L%20732%20249%20L%20693%20242%20L%20620%20239%20L%20672%20251%20L%20688%20260%20L%20701%20274%20L%20706%20287%20L%20704%20301%20L%20707%20297%20L%20711%20300%20L%20713%20309%20L%20720%20324%20L%20723%20327%20L%20724%20308%20L%20726%20304%20L%20735%20318%20L%20757%20341%20L%20753%20319%20L%20754%20313%20L%20760%20317%20L%20786%20345%20L%20784%20324%20L%20775%20300%20L%20779%20300%20L%20800%20317%20L%20800%20312%20L%20787%20283%20L%20772%20264%20L%20778%20263%20L%20800%20271%20L%20803%20270%20L%20770%20236%20L%20772%20234%20L%20797%20234%20L%20807%20230%20L%20801%20230%20L%20800%20228%20L%20811%20218%20L%20818%20203%20L%20818%20193%20L%20812%20179%20L%20798%20168%20L%20784%20166%20L%20760%20173%20L%20747%20173%20L%20712%20163%20L%20718%20157%20L%20703%20171%20L%20711%20173%20L%20720%20178%20L%20723%20182%20L%20720%20186%20L%20695%20190%20L%20680%20190%20L%20670%20193%20L%20656%20193%20L%20687%20204%20L%20729%20209%20L%20742%20214%20L%20753%20223%20L%20752%20224%20L%20729%20219%20L%20669%20212%20L%20610%20213%20L%20576%20218%20L%20565%20221%20L%20565%20219%20Z%20M%20771%20189%20L%20772%20201%20L%20775%20207%20L%20780%20212%20L%20787%20215%20L%20796%20216%20L%20796%20218%20L%20784%20219%20L%20776%20215%20L%20771%20210%20L%20768%20204%20L%20768%20194%20Z%20M%20788%20181%20L%20793%20182%20L%20797%20187%20L%20796%20192%20L%20790%20196%20L%20786%20195%20L%20782%20191%20L%20782%20186%20Z'/%3e%3c/g%3e%3c/svg%3e`;function he({className:e}){return(0,I.jsx)(`span`,{className:`block overflow-hidden rounded-[20.7%] bg-accent ${e??``}`,children:(0,I.jsx)(`img`,{src:me,alt:``,"aria-hidden":`true`,className:`h-full w-full`})})}function ge({open:e}){return(0,I.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2.5`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-3.5 w-3.5 shrink-0 transition-transform ${e?`rotate-90`:``}`,children:(0,I.jsx)(`path`,{d:`m9 18 6-6-6-6`})})}function _e({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M3 6h.01`}),(0,I.jsx)(`path`,{d:`M3 12h.01`}),(0,I.jsx)(`path`,{d:`M3 18h.01`}),(0,I.jsx)(`path`,{d:`M8 6h13`}),(0,I.jsx)(`path`,{d:`M8 12h13`}),(0,I.jsx)(`path`,{d:`M8 18h13`})]})}function ve({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`}),(0,I.jsx)(`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`}),(0,I.jsx)(`path`,{d:`M10 9H8`}),(0,I.jsx)(`path`,{d:`M16 13H8`}),(0,I.jsx)(`path`,{d:`M16 17H8`})]})}function ye({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`path`,{d:`m4 17 6-6-6-6`}),(0,I.jsx)(`path`,{d:`M12 19h8`})]})}function be({repos:e,currentId:t,onSelect:n,onCloseProject:r,onOpenPicker:i,className:a=``}){let[o,s]=(0,v.useState)(!1),c=(0,v.useRef)(null),l=e.find(e=>e.id===t);return(0,v.useEffect)(()=>{if(!o)return;let e=e=>{e.key===`Escape`&&(s(!1),c.current?.focus())};return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[o]),(0,I.jsxs)(`div`,{className:`relative ${a}`,children:[(0,I.jsxs)(`button`,{ref:c,onClick:()=>s(e=>!e),"aria-haspopup":`menu`,"aria-expanded":o,title:l?.display_path??`Select a project`,className:`flex max-w-[9rem] items-center gap-1 rounded-sm bg-ink-700 py-0.5 pl-2 pr-1 text-ink-50`,children:[(0,I.jsx)(`span`,{className:`truncate`,children:l?.name??`No project`}),(0,I.jsx)(ge,{open:o})]}),o&&(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`div`,{className:`fixed inset-0 z-40`,onClick:()=>s(!1)}),(0,I.jsxs)(`div`,{role:`menu`,className:`absolute left-0 z-50 mt-1 max-h-[70vh] w-56 max-w-[80vw] overflow-y-auto rounded-md border border-ink-700 bg-ink-900 py-1 shadow-lg`,children:[e.length===0&&(0,I.jsx)(`p`,{className:`px-3 py-1.5 text-ink-400`,children:`No projects open.`}),e.map(e=>(0,I.jsxs)(`div`,{className:`flex items-center ${e.id===t?`bg-ink-700 text-ink-50`:`text-ink-200`}`,children:[(0,I.jsx)(`button`,{role:`menuitem`,onClick:()=>{n(e.id),s(!1)},title:e.display_path,className:`min-w-0 flex-1 truncate py-1.5 pl-3 pr-1 text-left hover:text-accent`,children:e.name}),(0,I.jsx)(`button`,{onClick:()=>r(e.id),"aria-label":`close ${e.name}`,title:`Close project`,className:`mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed`,children:(0,I.jsx)(ce,{className:`h-3.5 w-3.5`})})]},e.id)),(0,I.jsx)(`div`,{className:`my-1 border-t border-ink-800`}),(0,I.jsxs)(`button`,{role:`menuitem`,onClick:()=>{i(),s(!1)},className:`flex w-full items-center gap-1 px-3 py-1.5 text-left text-ink-400 hover:text-ink-200`,children:[(0,I.jsx)(le,{className:`h-3.5 w-3.5`}),`open`]})]})]})]})}function xe(){let[e,t]=(0,v.useState)(!1),n=(0,v.useRef)(!1);return{reload:(0,v.useCallback)(async()=>{if(!n.current){n.current=!0,t(!0);try{N.success(await k.reloadConfig())}catch(e){N.error(e instanceof Error?e.message:`could not reload the config`)}finally{n.current=!1,t(!1)}}},[]),pending:e}}function Se(e,t=14){let n=[...e];return n.length<=t?e:`${n.slice(0,Math.max(0,t-1)).join(``)}…`}function Ce({repos:e,repo:t,onSelectRepo:n,onCloseRepo:r,onOpenPicker:i,cloning:a,accent:o,next:s,cycle:c,draggingRepo:l,dragOverRepo:u,onRepoDragStart:d,onRepoDragMove:f,onRepoDragEnd:p}){let{reload:m,pending:h}=xe();return(0,I.jsxs)(`header`,{className:`flex items-center gap-2 border-b border-ink-700 bg-ink-900 px-[12.8px] py-[8.8px]`,children:[(0,I.jsx)(he,{className:`h-[22px] w-[22px] shrink-0`}),(0,I.jsx)(`span`,{className:`text-[16px] font-medium tracking-[0.04em] text-ink-50`,children:`nightcrow`}),(0,I.jsx)(`span`,{className:`hidden font-sans text-[10px] uppercase tracking-[0.18em] text-ink-400 sm:inline`,children:`web viewer`}),(0,I.jsx)(be,{className:`md:hidden`,repos:e,currentId:t,onSelect:n,onCloseProject:r,onOpenPicker:i}),(0,I.jsx)(`nav`,{className:`-my-[8.8px] hidden items-stretch self-stretch overflow-x-auto pl-1 md:flex`,children:e.map(i=>(0,I.jsxs)(`div`,{"data-repo-id":i.id,onPointerDown:e=>d(e,i.id),onPointerMove:f,onPointerUp:p,onPointerCancel:p,onLostPointerCapture:p,className:`flex items-center border-r border-ink-700 whitespace-nowrap ${e.length>1?`touch-none`:``} ${l===i.id?`opacity-60`:``} ${u===i.id?`bg-ink-800 ring-1 ring-inset ring-accent`:``} ${i.id===t?`bg-ink-950 text-ink-50 shadow-[inset_0_2px_0_0_var(--color-accent)]`:`text-ink-400 hover:bg-ink-850 hover:text-ink-200`}`,title:i.display_path,children:[(0,I.jsx)(`button`,{onClick:()=>{n(i.id)},"aria-label":i.name,className:`self-stretch pl-3 pr-1`,children:Se(i.name)}),(0,I.jsx)(`button`,{onClick:e=>{e.stopPropagation(),r(i.id)},"data-tab-close":!0,title:`Close project`,"aria-label":`close ${i.name}`,className:`mr-1 flex h-5 w-5 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-removed`,children:(0,I.jsx)(ce,{className:`h-3.5 w-3.5`})})]},i.id))}),(0,I.jsxs)(`button`,{onClick:i,title:`Open a project`,className:`hidden shrink-0 items-center gap-1 rounded-sm px-2 py-0.5 text-ink-400 hover:text-ink-200 md:inline-flex`,children:[(0,I.jsx)(le,{className:`h-3.5 w-3.5`}),`open`]}),a&&(0,I.jsxs)(`span`,{role:`status`,title:`A clone is running on the server`,className:`flex shrink-0 items-center gap-1.5 px-2 py-0.5 text-ink-400`,children:[(0,I.jsx)(`span`,{"aria-hidden":`true`,className:`h-1.5 w-1.5 animate-pulse rounded-full bg-accent`}),`Cloning…`]}),(0,I.jsx)(`button`,{onClick:c,title:`Accent: ${o.name} (click for ${s.name})`,"aria-label":`accent colour: ${o.name}, click for ${s.name}`,className:`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-sm`,children:(0,I.jsx)(`span`,{"aria-hidden":`true`,className:`h-3 w-3 rounded-full bg-accent ring-1 ring-ink-600`})}),(0,I.jsx)(`button`,{onClick:m,disabled:h,title:`Reload config.toml on the server (does not reload this page)`,"aria-label":`reload the server config`,className:`ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200 disabled:cursor-progress disabled:text-ink-500 disabled:hover:bg-transparent`,children:(0,I.jsx)(fe,{className:`h-3.5 w-3.5 ${h?`animate-spin`:``}`})}),(0,I.jsx)(`a`,{href:`/logout`,title:`Sign out`,"aria-label":`sign out`,className:`ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200`,children:(0,I.jsx)(de,{className:`h-3.5 w-3.5`})})]})}function we(){return(0,I.jsx)(`div`,{className:`flex h-full items-center justify-center p-6`,children:(0,I.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-ink-400`,children:[(0,I.jsx)(he,{className:`h-12 w-12 animate-pulse`}),(0,I.jsx)(`span`,{className:`text-[0.72rem] tracking-[0.18em] uppercase`,children:`Loading…`})]})})}function Te({onSuccess:e}){let[t,n]=(0,v.useState)(``),[r,i]=(0,v.useState)(null),[a,o]=(0,v.useState)(!1);return(0,I.jsx)(`div`,{className:`flex h-full items-center justify-center p-6`,children:(0,I.jsxs)(`form`,{onSubmit:async n=>{n.preventDefault(),o(!0),i(null);try{await k.login(t),e()}catch(e){i(e instanceof Error?e.message:`login failed`)}finally{o(!1)}},className:`w-[17rem] max-w-[86vw]`,children:[(0,I.jsx)(he,{className:`mx-auto mb-3 block h-10 w-10`}),(0,I.jsx)(`h1`,{className:`text-center text-lg font-medium tracking-wide text-ink-50`,children:`nightcrow`}),(0,I.jsx)(`p`,{className:`mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase`,children:`web viewer`}),r&&(0,I.jsx)(`p`,{className:`mb-2.5 text-center text-removed`,children:r}),(0,I.jsx)(`input`,{type:`password`,autoFocus:!0,value:t,onChange:e=>n(e.target.value),placeholder:`password`,className:`mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15`}),(0,I.jsx)(`button`,{type:`submit`,disabled:a,className:`w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50`,children:a?`Signing in…`:`Sign in`})]})})}var Ee=`nightcrow.sidebarWidth`,De=.5;function Oe(e){return Math.min(Math.max(Math.round(e),280),720)}function ke(e){let t=720;try{t=Math.min(t,Math.round(window.innerWidth*De))}catch{}return Math.min(Math.max(Math.round(e),280),Math.max(t,280))}function Ae(){try{let e=Number(localStorage.getItem(Ee));return Number.isFinite(e)&&e>0?Oe(e):460}catch{return 460}}function je(e){try{localStorage.setItem(Ee,String(e))}catch{}}function Me(){let[e,t]=(0,v.useState)(Ae);return{width:e,resize:(0,v.useCallback)(e=>{let n=ke(e);t(n),je(n)},[]),commit:(0,v.useCallback)(e=>{let n=ke(e);t(n),je(n),k.setSidebarWidth(n).catch(()=>{})},[]),reset:(0,v.useCallback)(()=>{let e=Oe(460);t(e),je(e),k.setSidebarWidth(e).catch(()=>{})},[]),adopt:(0,v.useCallback)(e=>{t(t=>{let n=Oe(e);return n===t?t:(je(n),n)})},[])}}function Ne(){let e=new Map;return{start(t){let n=(e.get(t)??0)+1;return e.set(t,n),n},isCurrent(t,n){return e.get(t)===n}}}var Pe={children:{},expanded:new Set};function Fe(e,t,n){return{...e,children:{...e.children,[t]:n}}}function Ie(e,t){let n=new Set(e);return n.delete(t)||n.add(t),n}function Le(e,t){return{...e,expanded:Ie(e.expanded,t)}}function Re(e,t){let n=new Set(e.expanded);return t.forEach(e=>n.add(e)),{...e,expanded:n}}function ze(e){let t=[],n=``;for(let r of e.split(`/`))n=n?`${n}/${r}`:r,t.push(n);return t}var Be=180,Ve={items:[],truncated:!1};function He({repo:e,authed:t,tab:n,filter:r,filterOpen:i,handle:a}){let[o,s]=(0,v.useState)(Pe),[c,l]=(0,v.useState)(Ve),[u,d]=(0,v.useState)(!1),[f]=(0,v.useState)(Ne);(0,v.useEffect)(()=>{if(!e||!t||n!==`tree`||!i||!r){l(Ve),d(!1);return}d(!0);let o=!0,s=setTimeout(()=>{k.treeSearch(e,r).then(e=>{o&&l({items:e.matches,truncated:e.truncated})}).catch(e=>{o&&a(e)}).finally(()=>{o&&d(!1)})},Be);return()=>{o=!1,clearTimeout(s)}},[e,t,n,r,i,a]);let p=(0,v.useCallback)((t,n)=>{if(!e)return;let r=f.start(t);k.tree(e,t).then(e=>{f.isCurrent(t,r)&&s(n=>Fe(n,t,e.entries))}).catch(e=>{if(!f.isCurrent(t,r)||n?.restoring)return x(e)?a(e):void 0;a(e)})},[e,a,f]);(0,v.useEffect)(()=>{!e||!t||n!==`tree`||p(``)},[e,t,n,p]);let m=(0,v.useCallback)(e=>{let t=!o.expanded.has(e);s(t=>Le(t,e)),t&&!(e in o.children)&&p(e)},[o,p]),h=(0,v.useCallback)(e=>{s(t=>({...t,expanded:new Set(e)})),e.forEach(e=>{e in o.children||p(e,{restoring:!0})})},[o,p]),g=(0,v.useCallback)(e=>{let t=ze(e);s(e=>Re(e,t)),t.forEach(e=>{e in o.children||p(e)})},[o,p]);return{treeChildren:o.children,treeExpanded:o.expanded,treeMatches:c.items,treeTruncated:c.truncated,treeSearchLoading:u,loadTreeChildren:p,toggleTreeDir:m,revealTreeDir:g,seedTreeExpanded:h}}function Ue(e,t){let n=[],r=(i,a)=>{for(let o of e[i]??[]){let e=i?`${i}/${o.name}`:o.name;n.push({path:e,name:o.name,is_dir:o.is_dir,depth:a}),o.is_dir&&t.has(e)&&r(e,a+1)}};return r(``,0),n}function We({path:e,from:t,className:n}){return(0,I.jsx)(`span`,{className:`whitespace-nowrap ${n??``}`,title:t?`${t} → ${e}`:e,children:t?`${t} → ${e}`:e})}var Ge=1e3;function Ke(e,t){return e===void 0||e<=0?0:e-t}function qe(e,t,n){let r=Ke(t,n);return e===null||Math.abs(r-e)>=1e3?r:e}function Je(e,t,n){if(e===void 0)return`cool`;let r=Math.max(0,t-e);return r>=n?`cool`:r<5e3?`fresh`:`warm`}function Ye(e,t,n){return e.some(e=>Je(e,t,n)!==`cool`)}var Xe={fresh:`text-accent font-bold`,warm:`text-accent`,cool:``};function Ze(e,t,n){let[r,i]=(0,v.useState)(()=>Date.now()+n);return(0,v.useEffect)(()=>{if(t<=0||!e)return;let r=e.map(e=>e.mtime),a=Date.now()+n;if(i(a),!Ye(r,a,t))return;let o=setInterval(()=>{let e=Date.now()+n;i(e),Ye(r,e,t)||clearInterval(o)},Ge);return()=>clearInterval(o)},[e,t,n]),r}function Qe(e){let t=Math.max(0,Math.floor(Date.now()/1e3-e));return t<60?`${t}s`:t<3600?`${Math.floor(t/60)}m`:t<86400?`${Math.floor(t/3600)}h`:t<86400*30?`${Math.floor(t/86400)}d`:t<86400*365?`${Math.floor(t/(86400*30))}mo`:`${Math.floor(t/(86400*365))}y`}function $e(e){return e===`+`?`bg-added/10`:e===`-`?`bg-removed/10`:``}function et(e){return e===`?`?`text-ink-400`:e===`D`?`text-removed`:e===`A`?`text-added`:`text-accent`}function tt({status:e,files:t,now:n,hotWindowMs:r,openDiff:i}){return e===null?(0,I.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`Loading…`}):(0,I.jsxs)(I.Fragment,{children:[t.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsxs)(`button`,{onClick:()=>i(e.path),className:`flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,I.jsxs)(`span`,{className:`shrink-0`,children:[(0,I.jsx)(`span`,{className:et(e.index),children:e.index===` `?` `:e.index}),(0,I.jsx)(`span`,{className:et(e.worktree),children:e.worktree===` `?` `:e.worktree})]}),(0,I.jsx)(We,{path:e.path,from:e.old_path,className:Xe[Je(e.mtime,n,r)]})]})},e.path)),e.truncated&&(0,I.jsxs)(`li`,{className:`px-3 py-1 text-accent`,children:[`Showing the first `,e.files.length,` changed files.`]})]})}function nt({visibleCommits:e,commits:t,aheadOids:n,commitDrillDown:r,visibleCommitFiles:i,logDone:a,logStalled:o,logPagingPaused:s,setLogStalled:c,logSentinelRef:l,openCommitFiles:u,openCommit:d,openCommitFileDiff:f,setCommitDrillDown:p,setPaneEmpty:m,bumpPaneRequest:h}){return(0,I.jsxs)(I.Fragment,{children:[!r&&e.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsxs)(`button`,{onClick:()=>void u(e),title:`${e.author} · ${e.summary}`,className:`flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,I.jsx)(`span`,{className:`w-2 shrink-0 text-added`,children:n.has(e.oid)?`↑`:``}),(0,I.jsx)(`span`,{className:`shrink-0 text-accent`,children:e.short_id}),(0,I.jsx)(`span`,{className:`w-10 shrink-0 text-right text-ink-400`,children:Qe(e.time)}),(0,I.jsx)(`span`,{className:`max-w-[6rem] shrink-0 truncate text-ink-400`,children:e.author}),(0,I.jsx)(`span`,{className:`whitespace-nowrap`,children:e.summary})]})},e.oid)),!r&&!a&&!o&&!s&&(0,I.jsx)(`li`,{ref:l,className:`px-3 py-1 text-ink-400`,"aria-hidden":`true`,children:`loading…`}),!r&&!a&&!o&&s&&(0,I.jsxs)(`li`,{className:`px-3 py-1 text-ink-400`,children:[`filtering `,t.length,` loaded commits — clear the filter to load more`]}),!r&&o&&(0,I.jsx)(`li`,{className:`px-3 py-1`,children:(0,I.jsx)(`button`,{onClick:()=>c(!1),className:`text-ink-400 hover:text-accent`,children:`could not load more — retry`})}),r&&(0,I.jsxs)(I.Fragment,{children:[(0,I.jsxs)(`li`,{className:`sticky top-0 z-10 flex w-max min-w-full items-center gap-1 bg-ink-900 px-2 py-1 text-ink-400`,children:[(0,I.jsx)(`button`,{onClick:()=>{h(),p(null),m()},className:`rounded-sm px-1 hover:text-accent`,title:`Back to commit log`,children:`< log`}),(0,I.jsx)(`span`,{className:`text-ink-600`,children:`·`}),(0,I.jsx)(`span`,{className:`shrink-0 text-accent`,children:r.commit.short_id}),(0,I.jsx)(`button`,{onClick:()=>d(r.commit.oid),className:`rounded-sm px-1 hover:text-accent`,title:`Show the complete commit diff`,children:`all changes`})]}),i.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsxs)(`button`,{onClick:()=>f(r.commit.oid,e.path),className:`flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850`,children:[(0,I.jsx)(`span`,{className:et(e.index),children:e.index}),(0,I.jsx)(We,{path:e.path,from:e.old_path})]})},e.path)),r.files.length===0&&(0,I.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`No changed files.`}),r.files.length>0&&i.length===0&&(0,I.jsx)(`li`,{className:`px-3 py-2 text-ink-400`,children:`No matching files.`}),r.truncated&&(0,I.jsxs)(`li`,{className:`px-3 py-1 text-accent`,children:[`Showing the first `,r.files.length,` files.`]})]})]})}function rt({treeSearching:e,treeMatches:t,treeTruncated:n,treeSearchLoading:r,treeRows:i,treeExpanded:a,openFile:o,revealTreeDir:s,toggleTreeDir:c}){return e?(0,I.jsxs)(I.Fragment,{children:[t.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsx)(`button`,{onClick:()=>{e.is_dir?s(e.path):o(e.path)},title:e.path,className:`w-max min-w-full whitespace-nowrap px-3 py-0.5 text-left hover:bg-ink-850`,children:e.is_dir?(0,I.jsxs)(`span`,{className:`text-accent`,children:[e.path,`/`]}):e.path})},e.path)),t.length===0&&(0,I.jsx)(`li`,{className:`px-3 py-0.5 text-ink-400`,children:r?`searching…`:`no matches`}),n&&(0,I.jsxs)(`li`,{className:`px-3 py-0.5 text-ink-400`,children:[`showing the first `,t.length,` matches`]})]}):(0,I.jsx)(I.Fragment,{children:i.map(e=>(0,I.jsx)(`li`,{children:(0,I.jsxs)(`button`,{onClick:()=>e.is_dir?c(e.path):o(e.path),title:e.path,style:{paddingLeft:`${e.depth*.75+.5}rem`},className:`flex w-max min-w-full items-center gap-1 py-0.5 pr-3 text-left hover:bg-ink-850`,children:[e.is_dir?(0,I.jsx)(ge,{open:a.has(e.path)}):(0,I.jsx)(`span`,{className:`h-3.5 w-3.5 shrink-0`}),(0,I.jsx)(`span`,{className:`whitespace-nowrap ${e.is_dir?`text-accent`:``}`,children:e.is_dir?`${e.name}/`:e.name})]})},e.path))})}function it(e){let{tab:t,setTab:n,filter:r,setFilter:i,filterOpen:a,setFilterOpen:o,status:s,files:c,now:l,hotWindowMs:u,openDiff:d,openFile:f,openCommit:p,openCommitFileDiff:m,openCommitFiles:h,repo:g,authed:_,handle:y,sidebarRef:b,draggingSidebar:x,onSidebarDragStart:S,onSidebarDragMove:C,onSidebarDragEnd:w,onSidebarDragCancel:T,filesMax:E,bumpPaneRequest:D,commits:O,logDone:ee,logStalled:k,setLogStalled:te,commitDrillDown:ne,setCommitDrillDown:A,resetLog:re,logSentinelRef:ie,visibleCommits:j,logPagingPaused:M,aheadOids:ae,visibleCommitFiles:oe,mobileView:se,restoreTree:N,restoreKnown:P,onTreeExpanded:F,clearPane:ce,touched:le}=e,de=He({repo:g,authed:_,tab:t,filter:r,filterOpen:a,handle:y}),fe=t===`tree`&&a&&r!==``,pe=Ue(de.treeChildren,de.treeExpanded),{seedTreeExpanded:me}=de,he=(0,v.useRef)(!1);return(0,v.useEffect)(()=>{he.current||!P||le||t===`tree`&&(he.current=!0,N.length!==0&&me(N))},[t,P,le,N,me]),(0,I.jsxs)(`section`,{ref:b,className:`relative min-h-0 flex-col overflow-hidden ${se===`files`?`flex`:`hidden md:flex`} ${E?`md:flex`:`border-ink-700 md:border-r`}`,children:[!E&&(0,I.jsx)(`div`,{role:`separator`,"aria-orientation":`vertical`,"aria-label":`Resize the file sidebar (double-click to reset)`,title:`Drag to resize · double-click to reset`,onPointerDown:S,onPointerMove:C,onPointerUp:w,onPointerCancel:T,onLostPointerCapture:w,className:`absolute -right-px top-0 z-20 hidden h-full w-1.5 cursor-col-resize touch-none md:block ${x?`bg-accent`:`hover:bg-accent`}`}),(0,I.jsxs)(`div`,{className:`flex shrink-0 items-stretch border-b border-ink-700 px-2`,children:[[`status`,`log`,`tree`].map(e=>(0,I.jsx)(`button`,{onClick:()=>{e!==t&&(D(),t===`log`&&(A(null),re()),n(e),ce())},"aria-current":e===t?`page`:void 0,className:`-mb-px border-b-2 px-2 py-1 ${e===t?`border-accent text-ink-50`:`border-transparent text-ink-400 hover:text-ink-200`}`,children:e},e)),(0,I.jsx)(`button`,{onClick:()=>{a&&i(``),o(e=>!e)},"aria-pressed":a,title:a?`Hide the filter`:`Filter the list`,"aria-label":a?`Hide the filter`:`Filter the list`,className:`my-1 ml-auto flex shrink-0 items-center rounded-sm px-1.5 hover:text-accent ${a?`text-ink-50`:`text-ink-400`}`,children:(0,I.jsx)(ue,{})})]}),a&&(0,I.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:`filter…`,autoFocus:!0,className:`mx-2 mb-1 shrink-0 rounded-sm bg-ink-850 px-2 py-1 outline-none placeholder:text-ink-400 focus:ring-1 focus:ring-accent`}),(0,I.jsxs)(`ul`,{className:`min-h-0 flex-1 overflow-auto`,children:[t===`status`&&(0,I.jsx)(tt,{status:s,files:c,now:l,hotWindowMs:u,openDiff:d}),t===`log`&&(0,I.jsx)(nt,{visibleCommits:j,commits:O,aheadOids:ae,commitDrillDown:ne,visibleCommitFiles:oe,logDone:ee,logStalled:k,logPagingPaused:M,setLogStalled:te,logSentinelRef:ie,openCommitFiles:h,openCommit:p,openCommitFileDiff:m,setCommitDrillDown:A,setPaneEmpty:ce,bumpPaneRequest:D}),t===`tree`&&(0,I.jsx)(rt,{treeSearching:fe,treeMatches:de.treeMatches,treeTruncated:de.treeTruncated,treeSearchLoading:de.treeSearchLoading,treeRows:pe,treeExpanded:de.treeExpanded,openFile:f,revealTreeDir:e=>{let t=new Set(de.treeExpanded);ze(e).forEach(e=>t.add(e)),F([...t]),de.revealTreeDir(e)},toggleTreeDir:e=>{F([...Ie(de.treeExpanded,e)]),de.toggleTreeDir(e)}})]})]})}function at(e){let t=[],n=[],r=[],i=()=>{let e=Math.max(n.length,r.length);for(let i=0;i{t(e=>e===`split`?`unified`:`split`)},[])}}var st=[`.md`,`.markdown`],ct=[`.html`,`.htm`];function lt(e){let t=e.toLowerCase();return st.some(e=>t.endsWith(e))}function ut(e){let t=e.toLowerCase();return ct.some(e=>t.endsWith(e))}function dt(e){return lt(e)||ut(e)}function ft(e){return e.map(e=>e.map(e=>e.t).join(``)).join(` +`)}function pt(e,t=0){if(new Set(e.hunks.map(t=>t.file_path??e.path)).size>1)return null;for(let n of e.hunks.slice(Math.max(0,t)))for(let e of n.lines)if(e.new_lineno!==void 0)return e.new_lineno;return null}function mt(e){return Math.max(0,e-1-2)}function ht(e,t){return t<=0?null:Math.min(e,t)}function gt(e,t){let n=0;return e.forEach((e,r)=>{e<=t&&(n=r)}),n}var _t=600;function vt(e){let[t,n]=(0,v.useState)({scrollTop:0,height:_t}),r=(0,v.useCallback)(()=>{let t=e.current;if(!t)return;let r={scrollTop:t.scrollTop,height:t.clientHeight||_t};n(e=>e.scrollTop===r.scrollTop&&e.height===r.height?e:r)},[e]);return(0,v.useLayoutEffect)(()=>{r();let t=e.current;if(!t||typeof ResizeObserver>`u`)return;let n=new ResizeObserver(r);return n.observe(t),()=>n.disconnect()},[e,r]),{viewport:t,refresh:r}}function yt(e,t,n,r=20,i=12){let a=Math.max(1,Math.ceil(n/r)),o=Math.min(Math.floor(Math.max(0,t)/r),Math.max(0,e-a)),s=o+a,c=Math.max(0,Math.min(e,o-i)),l=Math.max(c,Math.min(e,s+i));return{start:c,end:l,before:c*r,after:(e-l)*r}}function bt(e){return Math.max(0,e-1)*20}function L(e){return e.kind===`empty`||!e.source?null:{want:e.kind===`diff`?`file`:`diff`,source:e.source}}function xt(e){return e.index!==`D`&&e.worktree!==`D`}function St(e){return e.hunks.some(e=>e.lines.some(e=>e.old_lineno!==void 0||e.new_lineno!==void 0))}function Ct(e){return e.kind===`workdir`?`workdir:${e.path}`:`commit:${e.oid}:${e.path}`}function wt({maximized:e}){return(0,I.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:e?(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`path`,{d:`M8 3v3a2 2 0 0 1-2 2H3`}),(0,I.jsx)(`path`,{d:`M21 8h-3a2 2 0 0 1-2-2V3`}),(0,I.jsx)(`path`,{d:`M3 16h3a2 2 0 0 1 2 2v3`}),(0,I.jsx)(`path`,{d:`M16 21v-3a2 2 0 0 1 2-2h3`})]}):(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`path`,{d:`M8 3H5a2 2 0 0 0-2 2v3`}),(0,I.jsx)(`path`,{d:`M21 8V5a2 2 0 0 0-2-2h-3`}),(0,I.jsx)(`path`,{d:`M3 16v3a2 2 0 0 0 2 2h3`}),(0,I.jsx)(`path`,{d:`M16 21h3a2 2 0 0 0 2-2v-3`})]})})}function Tt(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`}),(0,I.jsx)(`path`,{d:`M12 3v18`})]})}function Et(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`rect`,{width:`18`,height:`13`,x:`3`,y:`8`,rx:`2`}),(0,I.jsx)(`path`,{d:`M3 8V6a2 2 0 0 1 2-2h5v4`})]})}function Dt(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`path`,{d:`M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z`}),(0,I.jsx)(`circle`,{cx:`12`,cy:`12`,r:`3`})]})}function Ot(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`rect`,{width:`20`,height:`14`,x:`2`,y:`5`,rx:`2`}),(0,I.jsx)(`path`,{d:`M6 9h.01`}),(0,I.jsx)(`path`,{d:`M10 9h.01`}),(0,I.jsx)(`path`,{d:`M14 9h.01`}),(0,I.jsx)(`path`,{d:`M18 9h.01`}),(0,I.jsx)(`path`,{d:`M8 13h8`})]})}function kt({className:e=`h-4 w-4`}){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`shrink-0 ${e}`,children:[(0,I.jsx)(`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`}),(0,I.jsx)(`path`,{d:`M12 17v4`}),(0,I.jsx)(`path`,{d:`M8 21h8`}),(0,I.jsx)(`path`,{d:`m9 13 6-6`}),(0,I.jsx)(`path`,{d:`M9 10v3h3`}),(0,I.jsx)(`path`,{d:`M15 10V7h-3`})]})}function At(){return(0,I.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,focusable:`false`,className:`h-4 w-4`,children:[(0,I.jsx)(`path`,{d:`M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z`}),(0,I.jsx)(`path`,{d:`M14 3v5h5`}),(0,I.jsx)(`path`,{d:`M9 13h6`}),(0,I.jsx)(`path`,{d:`M9 17h3`})]})}var jt=3;function Mt(e){let t=e<1?1:String(Math.floor(e)).length;return Math.max(t,jt)}function Nt(e){let t=0;for(let n of e)for(let e of n.lines)t=Math.max(t,e.old_lineno??0,e.new_lineno??0);return Mt(t)}function Pt({nos:e,digits:t,tint:n=``,stickyClass:r=`sticky left-0`}){return(0,I.jsx)(`span`,{className:`${r} shrink-0 select-none bg-ink-950`,children:(0,I.jsx)(`span`,{className:`flex gap-[1ch] px-[1ch] text-ink-400 ${n}`,children:e.map((e,n)=>(0,I.jsx)(`span`,{className:`text-right`,style:{width:`${t}ch`},children:e??``},n))})})}function Ft(){let e=`(min-width: 768px)`,[t,n]=(0,v.useState)(()=>window.matchMedia(e).matches);return(0,v.useEffect)(()=>{let t=window.matchMedia(e),r=()=>n(t.matches);return t.addEventListener(`change`,r),()=>t.removeEventListener(`change`,r)},[]),t}function It(e,t,n){return e.hunks.flatMap((e,r)=>{let i={kind:`header`,hunk:r,text:`${e.file_path?`${e.file_path} `:``}${e.header}`};if(!t)return[i,...e.lines.map(e=>({kind:`unified`,hunk:r,line:e}))];let a=at(e.lines);return n?[i,...a.map(({left:e,right:t})=>({kind:`pair`,hunk:r,left:e,right:t}))]:[i,...a.map(({left:e})=>({kind:`side`,hunk:r,line:e,side:`old`,border:!1})),...a.map(({right:e},t)=>({kind:`side`,hunk:r,line:e,side:`new`,border:t===0}))]})}function Lt({line:e}){return(0,I.jsxs)(`span`,{className:`whitespace-pre pr-3`,children:[(0,I.jsx)(`span`,{className:`select-none text-ink-400`,children:e.kind}),e.spans.map((e,t)=>(0,I.jsx)(`span`,{style:{color:e.c},children:e.t},t))]})}function Rt({line:e,side:t,digits:n,stickyClass:r}){let i=e?$e(e.kind):`bg-ink-900/40`;return(0,I.jsxs)(`div`,{className:`flex min-w-0 flex-1 ${i}`,children:[(0,I.jsx)(Pt,{nos:[e?t===`old`?e.old_lineno:e.new_lineno:void 0],digits:n,tint:i,stickyClass:r}),e?(0,I.jsx)(Lt,{line:e}):(0,I.jsx)(`span`,{className:`pr-3`,children:` `})]})}function zt({diff:e,split:t,viewport:n}){let r=Ft(),i=(0,v.useMemo)(()=>It(e,t,r),[e,t,r]),a=Nt(e.hunks),o=yt(i.length,n.scrollTop,n.height);return(0,I.jsxs)(`div`,{className:`min-w-full py-1`,"data-virtual-count":i.length,children:[(0,I.jsx)(`div`,{"aria-hidden":`true`,style:{height:o.before}}),i.slice(o.start,o.end).map((e,t)=>{let n=o.start+t,r={"data-hunk":e.hunk,"data-virtual-row":n};if(e.kind===`header`)return(0,I.jsx)(`div`,{...r,className:`h-5 bg-ink-850 px-3 text-ink-400`,children:e.text},n);if(e.kind===`unified`){let t=$e(e.line.kind);return(0,I.jsxs)(`div`,{...r,className:`flex h-5 w-max min-w-full ${t}`,children:[(0,I.jsx)(Pt,{nos:[e.line.old_lineno,e.line.new_lineno],digits:a,tint:t}),(0,I.jsx)(Lt,{line:e.line})]},n)}return e.kind===`pair`?(0,I.jsxs)(`div`,{...r,className:`flex h-5 min-w-full`,children:[(0,I.jsx)(Rt,{line:e.left,side:`old`,digits:a}),(0,I.jsx)(`div`,{className:`flex min-w-0 flex-1 border-l border-ink-800`,children:(0,I.jsx)(Rt,{line:e.right,side:`new`,digits:a,stickyClass:`sticky left-1/2`})})]},n):(0,I.jsx)(`div`,{...r,className:`flex h-5 min-w-full ${e.border?`border-t border-ink-800`:``}`,children:(0,I.jsx)(Rt,{line:e.line,side:e.side,digits:a})},n)}),(0,I.jsx)(`div`,{"aria-hidden":`true`,style:{height:o.after}})]})}function Bt({line:e}){return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`span`,{className:`text-ink-400 select-none`,children:e.kind}),e.spans.map((e,t)=>(0,I.jsx)(`span`,{style:{color:e.c},children:e.t},t))]})}function Vt({line:e,digits:t,side:n}){if(e===null)return(0,I.jsxs)(`div`,{className:`flex bg-ink-900/40`,children:[(0,I.jsx)(Pt,{nos:[void 0],digits:t,tint:`bg-ink-900/40`}),(0,I.jsx)(`span`,{className:`whitespace-pre pr-3`,children:` `})]});let r=$e(e.kind);return(0,I.jsxs)(`div`,{className:`flex ${r}`,children:[(0,I.jsx)(Pt,{nos:[n===`old`?e.old_lineno:e.new_lineno],digits:t,tint:r}),(0,I.jsx)(`span`,{className:`whitespace-pre pr-3`,children:(0,I.jsx)(Bt,{line:e})})]})}function Ht({cells:e,digits:t,side:n,border:r}){return(0,I.jsx)(`div`,{className:`min-w-0 flex-none overflow-x-auto md:flex-1 md:basis-1/2 ${r?`border-t border-ink-800 md:border-t-0 md:border-l`:``}`,children:(0,I.jsx)(`div`,{className:`w-max min-w-full`,children:e.map((e,r)=>(0,I.jsx)(Vt,{line:e,digits:t,side:n},r))})})}function Ut({lines:e,digits:t}){let n=at(e);return(0,I.jsxs)(`div`,{className:`flex flex-col md:flex-row`,children:[(0,I.jsx)(Ht,{cells:n.map(e=>e.left),digits:t,side:`old`,border:!1}),(0,I.jsx)(Ht,{cells:n.map(e=>e.right),digits:t,side:`new`,border:!0})]})}function Wt({diff:e,split:t,viewport:n={scrollTop:0,height:600}}){if(e.hunks.reduce((e,t)=>e+t.lines.length,0)>200)return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(zt,{diff:e,split:t,viewport:n}),e.truncated&&(0,I.jsx)(`p`,{className:`p-3 text-accent`,children:`Diff truncated — it exceeded the server's size ceiling.`})]});let r=Nt(e.hunks);return(0,I.jsxs)(`div`,{className:`p-1`,children:[e.hunks.length===0&&(0,I.jsx)(`p`,{className:`p-3 text-ink-400`,children:`No changes.`}),e.hunks.map((e,n)=>{let i=(0,I.jsxs)(`div`,{className:`bg-ink-850 px-3 py-0.5 text-ink-400`,children:[e.file_path?`${e.file_path} `:``,e.header]});return(0,I.jsx)(`div`,{"data-hunk":n,className:`mb-2`,children:t?(0,I.jsxs)(I.Fragment,{children:[i,(0,I.jsx)(Ut,{lines:e.lines,digits:r})]}):(0,I.jsxs)(`div`,{className:`w-max min-w-full`,children:[i,e.lines.map((e,t)=>{let n=$e(e.kind);return(0,I.jsxs)(`div`,{className:`flex ${n}`,children:[(0,I.jsx)(Pt,{nos:[e.old_lineno,e.new_lineno],digits:r,tint:n}),(0,I.jsx)(`span`,{className:`whitespace-pre pr-3`,children:(0,I.jsx)(Bt,{line:e})})]},t)})]})},n)}),e.truncated&&(0,I.jsx)(`p`,{className:`p-3 text-accent`,children:`Diff truncated — it exceeded the server's size ceiling.`})]})}var Gt=[`failed to fetch dynamically imported module`,`error loading dynamically imported module`,`importing a module script failed`,`unable to preload css`];function Kt(e){let t=e instanceof Error?e.message:typeof e==`string`?e:``;if(!t)return!1;let n=t.toLowerCase();return Gt.some(e=>n.includes(e))}var qt=class extends v.Component{state={error:null,failed:!1};static getDerivedStateFromError(e){return{error:e,failed:!0}}componentDidCatch(e,t){console.error(`nightcrow: a subtree failed to render`,e,t)}render(){return this.state.failed?(0,I.jsx)(Jt,{chunk:Kt(this.state.error),region:this.props.region,className:this.props.className}):this.props.children}};function Jt({chunk:e,region:t,className:n}){return(0,I.jsxs)(`div`,{role:`alert`,className:`h-full min-h-0 flex-col items-start gap-3 p-4 text-ink-400 ${n??`flex`}`,children:[e?(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`p`,{className:`text-accent`,children:`Part of the app could not be loaded.`}),(0,I.jsx)(`p`,{children:`Most likely the server was updated while this tab was open, and reloading picks up the current version. If the reload fails too, the server is not reachable from here. Either way nothing on the server is affected — the session, its repositories, and its terminals are untouched.`})]}):(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(`p`,{className:`text-removed`,children:t?`The ${t} could not be rendered.`:`Something went wrong.`}),(0,I.jsx)(`p`,{children:`The details are in the browser console.`})]}),(0,I.jsx)(`button`,{onClick:()=>window.location.reload(),className:`rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:border-accent hover:text-accent`,children:`Reload`})]})}function Yt({lines:e}){let t=Mt(e.length);return(0,I.jsx)(`pre`,{className:`w-max min-w-full py-2 text-ink-200`,children:e.map((e,n)=>(0,I.jsxs)(`div`,{"data-line":n+1,className:`flex`,children:[(0,I.jsx)(Pt,{nos:[n+1],digits:t}),(0,I.jsx)(`span`,{className:`whitespace-pre pr-3`,children:e.length===0?` `:e.map((e,t)=>(0,I.jsx)(`span`,{style:{color:e.c},children:e.t},t))})]},n))})}function Xt({lines:e,viewport:t}){let n=Mt(e.length),r=yt(e.length,t.scrollTop,t.height);return(0,I.jsxs)(`pre`,{className:`w-max min-w-full`,"data-virtual-count":e.length,children:[(0,I.jsx)(`div`,{"aria-hidden":`true`,style:{height:r.before}}),e.slice(r.start,r.end).map((e,t)=>{let i=r.start+t;return(0,I.jsxs)(`div`,{"data-line":i+1,"data-virtual-row":i,className:`flex h-5 text-ink-200`,children:[(0,I.jsx)(Pt,{nos:[i+1],digits:n}),(0,I.jsx)(`span`,{className:`whitespace-pre pr-3`,children:e.length===0?` `:e.map((e,t)=>(0,I.jsx)(`span`,{style:{color:e.c},children:e.t},t))})]},i)}),(0,I.jsx)(`div`,{"aria-hidden":`true`,style:{height:r.after}})]})}var Zt=`modulepreload`,Qt=function(e,t){return new URL(e,t).href},$t={},en=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=Qt(t,n),t=s(t),t in $t)return;$t[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:Zt,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},tn=(0,v.lazy)(()=>en(()=>import(`./Markdown-CDxnZumR.js`).then(e=>({default:e.MarkdownView})),__vite__mapDeps([0,1]),import.meta.url)),nn=(0,v.lazy)(()=>en(()=>import(`./Html-ip9DjFbW.js`).then(e=>({default:e.HtmlView})),[],import.meta.url));function rn({repo:e,pane:t,previewRendered:n,setPreviewRendered:r,filesMax:i,setMaximized:a,showOtherFace:o,status:s,className:c=``}){let l=ot(),u=(0,v.useRef)(null),{viewport:d,refresh:f}=vt(u),p=t.kind===`file`?t.anchor:void 0,m=(0,v.useRef)(null),h=t=>`${e??``}\u0000${Ct(t)}`,g=()=>{let e=u.current;if(!e)return 0;let n=e.getBoundingClientRect().top,r=Array.from(e.querySelectorAll(`[data-hunk]`),t=>({offset:t.getBoundingClientRect().top-n+e.scrollTop,hunk:Number(t.dataset.hunk??0)}));return t.kind===`diff`&&t.source&&(m.current={key:h(t.source),top:e.scrollTop,left:e.scrollLeft}),r[gt(r.map(e=>e.offset),e.scrollTop)]?.hunk??0};return(0,v.useEffect)(()=>{let e=u.current;if(!e)return;if(t.kind===`diff`&&t.source){let n=m.current;n&&n.key===h(t.source)&&(e.scrollTop=n.top,e.scrollLeft=n.left,m.current=null,f());return}if(p===void 0||t.kind!==`file`)return;let n=ht(p,t.value.lines.length);if(n===null)return;if(t.value.lines.length>200){e.scrollTop=bt(n),f();return}let r=e.querySelector(`[data-line="${n}"]`);r&&(e.scrollTop+=r.getBoundingClientRect().top-e.getBoundingClientRect().top,f())},[t,p,f]),(0,I.jsxs)(`section`,{className:`min-h-0 min-w-0 flex-col ${c}`,children:[(0,I.jsxs)(`div`,{className:`flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400`,children:[t.kind===`file`&&(0,I.jsx)(We,{path:t.value.path}),(0,I.jsxs)(`div`,{className:`ml-auto flex shrink-0 items-center gap-1`,children:[L(t)&&(0,I.jsx)(`button`,{onClick:()=>o(g()),"aria-pressed":t.kind===`file`,title:t.kind===`file`?`Back to the diff`:`Open the whole file at this change`,"aria-label":t.kind===`file`?`Back to the diff`:`Open the whole file at this change`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${t.kind===`file`?`text-accent`:``}`,children:(0,I.jsx)(At,{})}),t.kind===`diff`&&(0,I.jsx)(`button`,{onClick:l.toggle,"aria-pressed":l.layout===`split`,title:l.layout===`split`?`Switch to unified diff`:`Switch to split diff`,"aria-label":l.layout===`split`?`Switch to unified diff`:`Switch to split diff`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${l.layout===`split`?`text-accent`:``}`,children:(0,I.jsx)(Tt,{})}),t.kind===`file`&&dt(t.value.path)&&(0,I.jsx)(`button`,{onClick:()=>r(e=>!e),"aria-pressed":n,title:n?`Show raw source`:`Show the rendered page`,"aria-label":n?`Show raw source`:`Show the rendered page`,className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${n?`text-accent`:``}`,children:(0,I.jsx)(Dt,{})}),(0,I.jsx)(`button`,{onClick:()=>a(i?`none`:`files`),"aria-pressed":i,title:i?`Restore the layout`:`Maximize the file pane`,"aria-label":i?`Restore the layout`:`Maximize the file pane`,className:`hidden shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent md:flex`,children:(0,I.jsx)(wt,{maximized:i})})]})]}),(0,I.jsxs)(`div`,{ref:u,onScroll:f,className:`min-h-0 flex-1 overflow-auto`,children:[t.kind===`empty`&&(0,I.jsx)(`p`,{className:`p-4 text-ink-400`,children:s===null?`Loading…`:`Select a file or commit.`}),t.kind===`file`&&(0,I.jsxs)(I.Fragment,{children:[dt(t.value.path)&&n?(0,I.jsx)(qt,{region:`preview`,children:(0,I.jsx)(v.Suspense,{fallback:(0,I.jsx)(`p`,{className:`p-4 text-ink-400`,children:`Rendering…`}),children:ut(t.value.path)&&e!==null?(0,I.jsx)(nn,{src:k.previewUrl(e,t.value.path,t.source?.kind===`commit`?t.source.oid:void 0)}):(0,I.jsx)(tn,{source:ft(t.value.lines)})})},t.value.path):t.value.lines.length>200?(0,I.jsx)(Xt,{lines:t.value.lines,viewport:d}):(0,I.jsx)(Yt,{lines:t.value.lines}),t.value.truncated&&(0,I.jsx)(`p`,{className:`p-3 text-accent`,children:`File truncated — it exceeded the server's size ceiling.`})]}),t.kind===`diff`&&(0,I.jsx)(Wt,{diff:t.value,split:l.layout===`split`,viewport:d})]})]})}var an=[{key:`files`,label:`Repo`,icon:_e},{key:`diff`,label:`Content`,icon:ve},{key:`terminal`,label:`Terminal`,icon:ye}];function on({view:e,onSelect:t}){return(0,I.jsx)(`nav`,{"aria-label":`Switch view`,className:`flex shrink-0 items-stretch border-t border-ink-700 bg-ink-900 md:hidden`,children:an.map(({key:n,label:r,icon:i})=>(0,I.jsxs)(`button`,{onClick:()=>t(n),"aria-current":e===n?`page`:void 0,className:`flex min-h-11 flex-1 flex-col items-center justify-center gap-0.5 py-1 text-[11px] ${e===n?`text-accent shadow-[inset_0_2px_0_0_var(--color-accent)]`:`text-ink-400`}`,children:[(0,I.jsx)(i,{className:`h-5 w-5`}),r]},n))})}var sn=(0,v.lazy)(()=>en(()=>import(`./Terminal-B_M7qCEW.js`).then(e=>({default:e.TerminalPanel})),[],import.meta.url));function cn({repository:{id:e,current:t,status:n},sidebar:r,filePane:i,layout:{sidebarWidth:a,sidebarRef:o,draggingSidebar:s,onSidebarDragStart:c,onSidebarDragMove:l,onSidebarDragEnd:u,onSidebarDragCancel:d,upperRef:f,lowerRef:p,draggingUpper:m,onUpperDragStart:h,onUpperDragMove:g,onUpperDragEnd:_,onUpperDragCancel:y,maximized:b,setMaximized:x,mobileView:S,setMobileView:C}}){let w=b===`files`;return(0,v.useEffect)(()=>d,[e,d]),(0,v.useEffect)(()=>y,[y]),(0,I.jsxs)(I.Fragment,{children:[s&&(0,I.jsx)(`div`,{className:`fixed inset-0 z-50 cursor-col-resize`}),m&&(0,I.jsx)(`div`,{className:`fixed inset-0 z-50 cursor-row-resize`}),(0,I.jsxs)(`main`,{ref:f,className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${S===`terminal`?`hidden md:grid`:``} ${s||m?`select-none`:``}`,style:{"--nc-sidebar":w?`0px`:`min(${a}px, ${De*100}vw)`},children:[(0,I.jsx)(it,{...r,repo:e,status:n,sidebarRef:o,draggingSidebar:s,onSidebarDragStart:c,onSidebarDragMove:l,onSidebarDragEnd:u,onSidebarDragCancel:d,filesMax:w,mobileView:S},e),(0,I.jsx)(rn,{...i,filesMax:w,setMaximized:x,status:n,className:S===`diff`?`flex`:`hidden md:flex`})]}),(0,I.jsx)(qt,{region:`terminal panel`,className:S===`terminal`?`flex`:`hidden md:flex`,children:(0,I.jsx)(v.Suspense,{fallback:null,children:(0,I.jsx)(sn,{repo:e,maximized:b===`terminal`,onToggleMaximized:()=>x(e=>e===`terminal`?`none`:`terminal`),className:S===`terminal`?`flex`:`hidden md:flex`,sectionRef:p,showDivider:b===`none`,draggingUpper:m,onUpperDragStart:h,onUpperDragMove:g,onUpperDragEnd:_,onUpperDragCancel:y})})}),(0,I.jsx)(on,{view:S,onSelect:C}),(0,I.jsxs)(`footer`,{className:`flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400`,children:[(0,I.jsx)(`span`,{className:`min-w-0 truncate`,children:t?.display_path}),n?.branch&&(0,I.jsx)(`span`,{className:`min-w-0 max-w-[50%] truncate text-accent`,children:n.branch}),n?.tracking&&(0,I.jsxs)(`span`,{className:`shrink-0`,children:[`↑`,n.tracking.ahead,` ↓`,n.tracking.behind]}),(0,I.jsx)(`span`,{className:`ml-auto shrink-0`,children:n?(0,I.jsx)(`span`,{className:`text-added`,children:`● live`}):`connecting…`})]})]})}function ln(e,t){return e?`grid-rows-[auto_minmax(0,1fr)_auto_auto] ${t===`terminal`?`md:grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]`:t===`files`?`md:grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]`:`md:grid-rows-[auto_minmax(0,var(--nc-upper))_minmax(0,var(--nc-lower))_auto]`}`:`grid-rows-[auto_1fr]`}var un=1e3,dn=3,fn=2e3;function pn(e,t){let[n,r]=(0,v.useState)(!1),i=(0,v.useRef)(!1),a=(0,v.useRef)(!1);(0,v.useEffect)(()=>(a.current=!1,()=>{a.current=!0}),[]);let o=(0,v.useCallback)(async t=>{for(;!a.current;){if(await new Promise(e=>setTimeout(e,un)),a.current)return;let n;try{n=await k.cloneStatus(t)}catch(e){if(x(e)){i.current=!1,a.current||r(!1);return}if(e instanceof b&&e.status===404){if(a.current)return;N.error(`the clone's progress is no longer available`),i.current=!1,r(!1);return}continue}if(a.current)return;if(n.state===`done`){try{let t=await k.open(n.path);if(a.current)return;e(t)}catch(e){if(a.current)return;N.error(e instanceof Error?e.message:`could not open`)}finally{i.current=!1,a.current||r(!1)}return}if(n.state===`failed`){N.error(n.message),i.current=!1,r(!1);return}}},[e]),s=(0,v.useCallback)(async(e=()=>!1)=>{for(let t=0;t0&&await new Promise(e=>setTimeout(e,fn)),i.current||e()||a.current)return;let n;try{({job:n}=await k.runningClone())}catch(e){if(x(e))return;continue}if(n===null||e()||a.current||i.current)return;i.current=!0,r(!0),o(n);return}},[o]);return(0,v.useEffect)(()=>{if(!t)return;let e=!1;return s(()=>e),()=>{e=!0}},[t,s]),{busy:n,start:(0,v.useCallback)(async(e,t)=>{if(!(!t.trim()||i.current)){i.current=!0,r(!0);try{let{job:n}=await k.clone(e,t.trim());await o(n)}catch(e){if(i.current=!1,a.current)return;let t=e instanceof b&&e.status>=400;N.error(t?e.message:`could not confirm the clone started — check this folder before retrying`),r(!1),s()}}},[o,s])}}function mn(e,t,n,r=!1){return r&&n&&t.includes(n)?n:e&&t.includes(e)?e:n&&t.includes(n)?n:t[0]??null}function hn(e){let t=!1,n=null,r=()=>{if(t||n===null)return;let i=n;n=null,t=!0,e(i).catch(()=>{}).finally(()=>{t=!1,r()})};return e=>{n=e,r()}}function gn(e,t,n){if(t===n)return e;let r=e.indexOf(t),i=e.indexOf(n);if(r===-1||i===-1)return e;let a=e.filter(e=>e!==t),o=a.indexOf(n),s=r{let r=t[n];return e.id===r.id&&e.name===r.name&&e.display_path===r.display_path})?e:t}var bn=`The viewer was updated on the server.`,xn=null,Sn=null,Cn=null;function wn(e,t){return e===null||t===null?!1:e!==t}function Tn(e){xn=e}function En(e){if(!wn(xn,e)){e===xn&&Sn!==null&&(Cn!==null&&ae(Cn),Sn=null,Cn=null);return}Sn!==e&&(Sn=e,Cn=N.info(bn,{sticky:!0,action:{label:`Reload`,run:()=>window.location.reload()}}))}var Dn=3e3;function On({authed:e,setAuthed:t,handle:n,adoptAccent:r,adoptSidebarWidth:i,adoptUpperPct:a,adoptMaximized:o,adoptViews:s,draggingRef:c,upperDraggingRef:l,accentWrites:u,sidebarWrites:d,upperPctWrites:f,maximizedWrites:p,viewWrites:m,resumeTick:h,orderWrites:g,repoDraggingRef:_,reorderInFlightRef:y,pendingReorderRef:b}){let[S,w]=(0,v.useState)([]),[T,E]=(0,v.useState)(null),[D,O]=(0,v.useState)(null),[ee,te]=(0,v.useState)(null),[ne,A]=(0,v.useState)(!1),[re,ie]=(0,v.useState)(!0),{current:j}=(0,v.useRef)(hn(k.setActiveRepo)),M=(0,v.useRef)(null),ae=(0,v.useRef)(null);return(0,v.useEffect)(()=>{if(e===!1)return;let h=!1,v,S=new AbortController,T=()=>{let e=u.current,D=d.current,ee=f.current,ne=p.current,re=m.current,j=g.current;return k.repos(S.signal).then(n=>{let{repos:x,hot:S,accent:C,sidebar_width:k,upper_pct:oe,active_repo:se,maximized:N,last_view:P,now_ms:F,can_clone:I,viewer_build:ce}=n;if(h)return;En(ce),O(e=>vn(e,S)),ie(I),te(e=>qe(e,F,Date.now())),u.current===e&&r(C),d.current===D&&!c.current&&i(k),f.current===ee&&!l.current&&a(oe),p.current===ne&&o(N),m.current===re&&s(P??{},x.map(e=>e.id)),t(!0),A(!0);let le=y.current||b.current!==null;g.current===j&&!_.current&&!le?w(e=>yn(e,x)):w(e=>{let t=_n(x.map(e=>e.id),e.map(e=>e.id)),n=new Map(x.map(e=>[e.id,e]));return yn(e,t.map(e=>n.get(e)).filter(Boolean))});let ue=x.map(e=>e.id),de=se!==M.current;M.current=se??null,de&&se&&ue.includes(se)&&(ae.current=se),E(e=>mn(e,ue,se,de)),h||(v=setTimeout(T,Dn))}).catch(e=>{if(!h){if(x(e)){t(!1),A(!1);return}else C(e)||n(e);v=setTimeout(T,Dn)}})};return T(),()=>{h=!0,S.abort(),v&&clearTimeout(v)}},[e,t,n,r,i,a,h,u,d,f,p,m,c,l,o,s,g,_,y,b]),(0,v.useEffect)(()=>{if(!T){ae.current=null;return}T!==ae.current&&(ae.current=null,j(T))},[T,j]),{repos:S,setRepos:w,repo:T,setRepo:E,hot:D,clockSkewMs:ee,reposLoaded:ne,canClone:re}}var kn=4;function An({ids:e,onReorder:t,draggingRef:n}){let r=(0,v.useRef)(null),i=(0,v.useRef)(null),a=(0,v.useRef)(null),[o,s]=(0,v.useState)(null),[c,l]=(0,v.useState)(null);return{dragging:o,target:c,onStart:(t,a)=>{t.target.closest(`button[data-tab-close]`)||t.button!==0||e.length<2||(r.current=a,i.current={x:t.clientX,y:t.clientY},n.current=!1)},onMove:e=>{let t=r.current,o=i.current;if(t===null||o===null)return;if(!n.current&&e.buttons===0){r.current=null,i.current=null;return}if(!n.current&&Math.hypot(e.clientX-o.x,e.clientY-o.y){let o=r.current,c=a.current;o!==null&&n.current&&c!==null&&t(gn(e,o,c)),r.current=null,i.current=null,a.current=null,n.current=!1,s(null),l(null)}}}function jn({repos:e,setRepos:t,handle:n,writesRef:r,draggingRef:i,inFlightRef:a,pendingRef:o}){let s=(0,v.useCallback)(()=>{if(a.current||o.current===null)return;let e=o.current;o.current=null,a.current=!0;let i=r.current;k.reorderRepos(e).then(e=>{r.current===i&&t(e)}).catch(n).finally(()=>{a.current=!1,s()})},[n,t]),c=(0,v.useCallback)(e=>{r.current+=1,t(t=>{let n=_n(t.map(e=>e.id),e),r=new Map(t.map(e=>[e.id,e]));return n.map(e=>r.get(e)).filter(Boolean)}),o.current=e,s()},[s,t]);return{...An({ids:e.map(e=>e.id),onReorder:c,draggingRef:i}),writesRef:r,draggingRef:i,inFlightRef:a,pendingRef:o}}function Mn({authed:e,setAuthed:t,handle:n,resumeTick:r,adoptAccent:i,adoptSidebarWidth:a,adoptUpperPct:o,adoptMaximized:s,adoptViews:c,accentWrites:l,sidebarWrites:u,upperPctWrites:d,maximizedWrites:f,viewWrites:p,draggingRef:m,upperDraggingRef:h}){let g=(0,v.useRef)(0),_=(0,v.useRef)(!1),y=(0,v.useRef)(!1),b=(0,v.useRef)(null),x=On({authed:e,setAuthed:t,handle:n,adoptAccent:i,adoptSidebarWidth:a,adoptUpperPct:o,adoptMaximized:s,adoptViews:c,draggingRef:m,upperDraggingRef:h,accentWrites:l,sidebarWrites:u,upperPctWrites:d,maximizedWrites:f,viewWrites:p,resumeTick:r,orderWrites:g,repoDraggingRef:_,reorderInFlightRef:y,pendingReorderRef:b}),{dragging:S,target:C,onStart:w,onMove:T,onEnd:E}=jn({repos:x.repos,setRepos:x.setRepos,handle:n,writesRef:g,draggingRef:_,inFlightRef:y,pendingRef:b});return{...x,orderWrites:g,draggingRepo:S,dragOverRepo:C,onRepoDragStart:w,onRepoDragMove:T,onRepoDragEnd:E}}function Nn(e,t){let n=e.indexOf(t);return n===-1?e[0]??null:e[n+1]??e[n-1]??null}function Pn({repo:e,repos:t,setRepos:n,setRepo:r,setPane:i,setTab:a,setPickerOpen:o,handle:s,orderWrites:c}){let l=(0,v.useRef)(t);l.current=t;let u=(0,v.useRef)(e);return u.current=e,{selectOpenedRepo:(0,v.useCallback)(e=>{c.current+=1,n(t=>t.some(t=>t.id===e.id)?t:[...t,e]),r(e.id),e.id!==u.current&&(i({kind:`empty`}),a(`status`)),o(!1)},[n,r,i,a,o,c]),closeRepo:(0,v.useCallback)(async e=>{try{await k.close(e),c.current+=1;let t=Nn(l.current.map(e=>e.id),e);n(t=>t.filter(t=>t.id!==e)),r(n=>n===e?t:n)}catch(e){s(e)}},[n,r,s,c])}}function Fn(e,t,n,r,i){(0,v.useLayoutEffect)(()=>{t&&(e.some(e=>e.oid===t.commit.oid)||(r(),n(null),i()))},[e,t,n,r,i])}function In(e,t,n){let r=!e.truncated||e.head===void 0,i=t[0]?.oid,a=i===void 0?-1:e.commits.findIndex(e=>e.oid===i),o=a<0?[]:e.commits.slice(a);return a>=0&&o.length<=t.length&&o.every((e,n)=>e.oid===t[n].oid)?{commits:a===0?t:[...e.commits.slice(0,a),...t],anchor:e.head??null,done:n||r,mode:`prepend`}:{commits:e.commits,anchor:e.head??null,done:r,mode:`replace`}}function Ln({repo:e,authed:t,tab:n,filter:r,head:i,handle:a}){let[o,s]=(0,v.useState)([]),[c,l]=(0,v.useState)(!1),[u,d]=(0,v.useState)(!1),f=(0,v.useRef)(null),p=(0,v.useRef)(!1),m=(0,v.useRef)(!1),h=(0,v.useRef)(0),g=(0,v.useRef)(void 0),_=(0,v.useRef)(void 0),y=(0,v.useCallback)(()=>{h.current+=1,p.current=!1,m.current=!1,s([]),f.current=null,l(!1),d(!1),g.current=void 0,_.current=void 0},[]),[b,x]=(0,v.useState)(null),S=(0,v.useRef)(o);S.current=o;let C=(0,v.useRef)(c);C.current=c;let w=(0,v.useRef)(i);w.current=i;let T=(0,v.useCallback)(async t=>{if(!e)return;_.current=w.current,h.current+=1;let n=h.current;p.current=!0,m.current=!0;try{let r=await k.log(e);if(n!==h.current)return;let i=t??{commits:S.current,done:C.current},a=In(r,i.commits,i.done);s(a.commits),f.current=a.anchor,g.current=a.anchor,a.anchor===w.current&&(_.current=void 0),l(a.done),d(!1)}catch(e){n===h.current&&(_.current=void 0,a(e),d(!0))}finally{n===h.current&&(p.current=!1,m.current=!1)}},[e,a]),E=(0,v.useCallback)(async()=>{if(!e||p.current)return;p.current=!0;let t=h.current;try{let n=f.current,r=await k.log(e,n===null?void 0:{from:n,skip:S.current.length});if(t!==h.current)return;if(s(e=>[...e,...r.commits]),f.current=r.head??null,l(!r.truncated||r.head===void 0),n===null){g.current=r.head??null;let e=w.current;e!==void 0&&e!==(r.head??null)&&T({commits:r.commits,done:!r.truncated||r.head===void 0})}}catch(e){t===h.current&&(a(e),d(!0))}finally{t===h.current&&(p.current=!1)}},[e,a,T]);(0,v.useEffect)(()=>{!e||!t||n!==`log`||o.length===0&&!c&&!u&&E()},[e,t,n,o.length,c,u,E]),(0,v.useEffect)(()=>{if(!e||!t||n!==`log`||i===void 0||u)return;let r=g.current;if(r!==void 0){if(r===i){m.current||(_.current=void 0);return}_.current!==i&&T()}},[e,t,n,i,u,T]);let D=o.filter(e=>e.summary.toLowerCase().includes(r.toLowerCase())),O=r!==``,ee=(0,v.useRef)(null);return(0,v.useEffect)(()=>{let e=ee.current;if(!e)return;let t=new IntersectionObserver(e=>{e.some(e=>e.isIntersecting)&&E()},{root:e.closest(`ul`),rootMargin:`400px`});return t.observe(e),()=>t.disconnect()},[E,c,u,O,b,n,D.length]),{commits:o,logDone:c,logStalled:u,setLogStalled:d,commitDrillDown:b,setCommitDrillDown:x,resetLog:y,logSentinelRef:ee,visibleCommits:D,logPagingPaused:O}}function Rn({repo:e,authed:t,resumeTick:n,tab:r,pane:i,setPane:a,handle:o,paneRequestRef:s}){let[c,l]=(0,v.useState)(null),u=(0,v.useRef)(i);u.current=i;let d=(0,v.useRef)(r);return d.current=r,(0,v.useLayoutEffect)(()=>{l(null)},[e,t]),(0,v.useEffect)(()=>{if(!(!e||!t))return te(e,l)},[e,t,n]),(0,v.useEffect)(()=>{if(!e||!c)return;let t=u.current;if(d.current!==`status`||t.kind!==`diff`)return;let n=t.value.path,r=c.files.find(e=>e.path===n);if(!r){a({kind:`empty`});return}let i=s.current,l=!0,f=()=>{let e=u.current;return l&&i===s.current&&e.kind===`diff`&&e.value.path===n};return k.diff(e,n).then(e=>{f()&&a({kind:`diff`,value:e,source:xt(r)&&St(e)?{kind:`workdir`,path:n}:void 0})}).catch(e=>{f()&&o(e)}),()=>{l=!1}},[c,e,o,u,d,s,a]),{status:c,paneRef:u,tabRef:d}}function zn({repo:e,authed:t,hot:n,clockSkewMs:r,resumeTick:i,handle:a}){let[o,s]=(0,v.useState)(`status`),[c,l]=(0,v.useState)(``),[u,d]=(0,v.useState)(!1),[f,p]=(0,v.useState)({kind:`empty`}),[m,h]=(0,v.useState)(e);m!==e&&(h(e),p({kind:`empty`}),s(`status`));let g=(0,v.useRef)(0),_=(0,v.useCallback)(()=>{g.current+=1},[]),y=(0,v.useCallback)(()=>p({kind:`empty`}),[]),{status:b}=Rn({repo:e,authed:t,resumeTick:i,tab:o,pane:f,setPane:p,handle:a,paneRequestRef:g}),x=n?.enabled?n.window_secs*1e3:0,S=Ze(b?.files,x,r??0),C=Ln({repo:e,authed:t,tab:o,filter:c,head:b?b.head??null:void 0,handle:a});(0,v.useLayoutEffect)(()=>{_(),C.setCommitDrillDown(null),C.resetLog()},[e,_,C.setCommitDrillDown,C.resetLog]);let w=c.toLowerCase(),T=(0,v.useMemo)(()=>(b?.files??[]).filter(e=>e.path.toLowerCase().includes(w)),[b?.files,w]),E=(0,v.useMemo)(()=>(C.commitDrillDown?.files??[]).filter(e=>e.path.toLowerCase().includes(w)||e.old_path?.toLowerCase().includes(w)),[C.commitDrillDown?.files,w]),D=(0,v.useMemo)(()=>new Set(C.commits.slice(0,b?.tracking?.ahead??0).map(e=>e.oid)),[C.commits,b?.tracking?.ahead]);return{screen:{tab:o,setTab:s,filter:c,setFilter:l,filterOpen:u,setFilterOpen:d,pane:f,setPane:p},request:{paneRequestRef:g,bumpPaneRequest:_,clearPane:y},status:{value:b,files:T,now:S,hotWindowMs:x},log:{...C,aheadOids:D,visibleCommitFiles:E}}}function Bn({repo:e,handle:t,setPane:n,paneRequestRef:r,setCommitDrillDown:i,setMobileView:a,setPreviewRendered:o,statusRef:s}){let c=(0,v.useCallback)(e=>{let t=s.current?.files.find(t=>t.path===e);return t?xt(t):!1},[s]);return{openDiff:(0,v.useCallback)((i,o)=>{if(!e)return;o?.restoring||a(`diff`);let s=r.current+=1;k.diff(e,i).then(e=>{s===r.current&&n({kind:`diff`,value:e,source:c(i)&&St(e)?{kind:`workdir`,path:i}:void 0})}).catch(e=>{if(s!==r.current)return x(e)?t(e):void 0;if(!o?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a,c]),openFile:(0,v.useCallback)((i,s)=>{if(!e)return;s?.restoring||a(`diff`),o(!0);let c=r.current+=1;k.file(e,i).then(e=>{c===r.current&&n({kind:`file`,value:e})}).catch(e=>{if(c!==r.current)return x(e)?t(e):void 0;if(!s?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a,o]),openCommit:(0,v.useCallback)(i=>{if(!e)return;a(`diff`);let o=r.current+=1;k.commit(e,i).then(e=>{o===r.current&&n({kind:`diff`,value:e})}).catch(e=>{o===r.current&&t(e)})},[e,t,n,r,a]),openCommitFileDiff:(0,v.useCallback)((i,o,s)=>{if(!e)return;s?.restoring||a(`diff`);let c=r.current+=1;k.commitFileDiff(e,i,o).then(e=>{c===r.current&&n({kind:`diff`,value:e,source:St(e)?{kind:`commit`,oid:i,path:o}:void 0})}).catch(e=>{if(c!==r.current)return x(e)?t(e):void 0;if(!s?.restoring)return t(e);x(e)&&t(e),n({kind:`empty`})})},[e,t,n,r,a]),openCommitFiles:(0,v.useCallback)(async o=>{if(!e)return;a(`diff`);let s=r.current+=1;try{let t=await k.commitFiles(e,o.oid);if(s!==r.current)return;if(i({commit:o,...t}),t.files.length===0){n({kind:`empty`});return}let a=await k.commit(e,o.oid);s===r.current&&n({kind:`diff`,value:a})}catch(e){s===r.current&&t(e)}},[e,t,n,r,i,a]),showOtherFace:(0,v.useCallback)((i,a=0)=>{let s=L(i);if(!e||!s)return;let{source:l}=s,u=s.want===`file`,d=u&&i.kind===`diff`?pt(i.value,a):null,f=r.current+=1,p=l.kind===`workdir`?u?k.file(e,l.path):k.diff(e,l.path):u?k.commitFile(e,l.oid,l.path):k.commitFileDiff(e,l.oid,l.path);u&&o(!1),p.then(e=>{f===r.current&&n(u?{kind:`file`,value:e,source:l,anchor:d===null?void 0:mt(d)+1}:{kind:`diff`,value:e,source:St(e)&&(l.kind!==`workdir`||c(l.path))?l:void 0})}).catch(e=>{f===r.current&&t(e)})},[e,t,n,r,o,c])}}function Vn({repo:e,handle:t,pane:n,setPane:r,paneRequestRef:i,setCommitDrillDown:a,status:o}){let[s,c]=(0,v.useState)(`files`),[l,u]=(0,v.useState)(!0),d=(0,v.useRef)(o);d.current=o;let f=Bn({repo:e,handle:t,setPane:r,paneRequestRef:i,setCommitDrillDown:a,setMobileView:c,setPreviewRendered:u,statusRef:d}),p=(0,v.useMemo)(()=>f,[f.openDiff,f.openFile,f.openCommit,f.openCommitFileDiff,f.openCommitFiles,f.showOtherFace]);return(0,v.useMemo)(()=>({openers:p,pane:n,setPane:r,previewRendered:l,setPreviewRendered:u,mobileView:s,setMobileView:c}),[p,n,r,l,s])}var Hn=[`status`,`log`,`tree`];function Un(){return{tab:`status`,file:null,tree_expanded:[]}}function Wn(e,t){return{path:e,commit:null,face:t}}function Gn(e,t,n){return{path:t,commit:e,face:n}}function Kn(e){return[...e].sort().slice(0,200)}function qn(e){let t=e?.file;return!t||!t.path?{kind:`none`}:t.commit?{kind:`commitDiff`,oid:t.commit,path:t.path}:t.face===`source`?{kind:`file`,path:t.path}:{kind:`diff`,path:t.path}}function Jn(e){let t=e?.tab;return t&&Hn.includes(t)?t:`status`}function Yn(e,t){return e?e.tab===t.tab&&e.tree_expanded.length===t.tree_expanded.length&&e.tree_expanded.every((e,n)=>e===t.tree_expanded[n])&&Xn(e.file,t.file):!1}function Xn(e,t){return!e||!t?e===t:e.path===t.path&&e.commit===t.commit&&e.face===t.face}function Zn({repo:e,known:t,remembered:n,latest:r,remember:i,setTab:a,openDiff:o,openFile:s,openCommitFileDiff:c}){let l=(0,v.useRef)(null),u=(0,v.useRef)(null),d=(0,v.useRef)(new Map),[f,p]=(0,v.useState)(!1),m=(0,v.useRef)(!1);(0,v.useEffect)(()=>{if(u.current!==e&&(u.current=e,l.current=null,m.current=!1,p(!1)),!e||!t||l.current===e)return;l.current=e;let r=d.current.get(e);d.current.delete(e);let f=r?{...n??Un(),...r}:n;if(r&&i(e,f),m.current)return;a(Jn(f));let h=qn(f);if(h.kind===`none`)return;let g={restoring:!0};h.kind===`diff`?o(h.path,g):h.kind===`file`?s(h.path,g):c(h.oid,h.path,g)},[e,t,n,i,a,o,s,c]);let h=(0,v.useCallback)(t=>{if(e){if(m.current=!0,p(!0),l.current!==e){let n=d.current.get(e);d.current.set(e,{...n,...t});return}i(e,{...r(e)??Un(),...t})}},[e,r,i]);return{touched:f,noteTab:(0,v.useCallback)(e=>h({tab:e}),[h]),noteFile:(0,v.useCallback)(e=>h({file:e}),[h]),noteTree:(0,v.useCallback)(e=>h({tree_expanded:Kn(e)}),[h])}}function Qn({repo:e,known:t,remembered:n,latest:r,remember:i,setTab:a,clearPane:o,openers:s}){let c=Zn({repo:e,known:t,remembered:n,latest:r,remember:i,setTab:a,openDiff:s.openDiff,openFile:s.openFile,openCommitFileDiff:s.openCommitFileDiff}),{noteFile:l,noteTab:u,noteTree:d}=c,f=(0,v.useCallback)(e=>{u(e),a(e)},[u,a]),p=(0,v.useCallback)(()=>{l(null),o()},[l,o]),m=(0,v.useMemo)(()=>({openDiff:e=>{l(Wn(e,`diff`)),s.openDiff(e)},openFile:e=>{l(Wn(e,`source`)),s.openFile(e)},openCommit:e=>{l(null),s.openCommit(e)},openCommitFiles:e=>(l(null),s.openCommitFiles(e)),openCommitFileDiff:(e,t)=>{l(Gn(e,t,`diff`)),s.openCommitFileDiff(e,t)}}),[s,l]),h=(0,v.useCallback)((e,t)=>{l(e.kind===`commit`?Gn(e.oid,e.path,t):Wn(e.path,t))},[l]);return{...c,noteTree:d,chooseTab:f,forgetPane:p,asked:m,noteOtherFace:h}}function $n({project:e,view:t,layout:n}){let{repo:r,repos:i,authed:a,hot:o,clockSkewMs:s,resumeTick:c,handle:l}=e,u=zn({repo:r,authed:a,hot:o,clockSkewMs:s,resumeTick:c,handle:l}),d=Vn({repo:r,handle:l,pane:u.screen.pane,setPane:u.screen.setPane,paneRequestRef:u.request.paneRequestRef,setCommitDrillDown:u.log.setCommitDrillDown,status:u.status.value}),f=Qn({repo:r,known:t.known,remembered:t.remembered,latest:t.latest,remember:t.remember,setTab:u.screen.setTab,clearPane:u.request.clearPane,openers:d.openers});Fn(u.log.commits,u.log.commitDrillDown,u.log.setCommitDrillDown,u.request.bumpPaneRequest,f.forgetPane);let p=n.maximizedPanelOf(r),m=n.setMaximizedFor,h=(0,v.useCallback)(e=>m(r,e),[r,m]),g=d.pane,_=d.openers.showOtherFace,y=f.noteOtherFace,b=(0,v.useCallback)(e=>{let t=L(g);t&&y(t.source,t.want===`file`?`source`:`diff`),_(g,e)},[g,y,_]);return{setPane:d.setPane,setTab:u.screen.setTab,clearPane:u.request.clearPane,maximized:p,repoShell:r?{repository:{id:r,current:i.find(e=>e.id===r),status:u.status.value},sidebar:{tab:u.screen.tab,filter:u.screen.filter,setFilter:u.screen.setFilter,filterOpen:u.screen.filterOpen,setFilterOpen:u.screen.setFilterOpen,files:u.status.files,now:u.status.now,hotWindowMs:u.status.hotWindowMs,...d.openers,...f.asked,setTab:f.chooseTab,authed:a,handle:l,bumpPaneRequest:u.request.bumpPaneRequest,...u.log,restoreTree:t.remembered?.tree_expanded??[],restoreKnown:t.known,onTreeExpanded:f.noteTree,clearPane:f.forgetPane,touched:f.touched},filePane:{repo:r,pane:d.pane,previewRendered:d.previewRendered,setPreviewRendered:d.setPreviewRendered,showOtherFace:b},layout:{...n.shell,maximized:p,setMaximized:h,mobileView:d.mobileView,setMobileView:d.setMobileView}}:null}}function er(){let[e,t]=(0,v.useState)(0);return(0,v.useEffect)(()=>{let e=()=>{document.visibilityState===`visible`&&t(e=>e+1)};return document.addEventListener(`visibilitychange`,e),window.addEventListener(`online`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`online`,e)}},[]),e}var tr=[{name:`yellow`,color:`#d9a441`},{name:`cyan`,color:`#03c4db`},{name:`green`,color:`#77c47a`},{name:`magenta`,color:`#dc8fd5`},{name:`blue`,color:`#87acfd`}],nr=`nightcrow.viewer.accent`;function rr(e){if(!Number.isFinite(e))return 0;let t=tr.length;return(Math.trunc(e)%t+t)%t}function ir(){try{let e=localStorage.getItem(nr);return e===null?0:rr(Number(e))}catch{return 0}}function ar(e){try{localStorage.setItem(nr,String(e))}catch{}}function or(){let[e,t]=(0,v.useState)(ir);(0,v.useLayoutEffect)(()=>{document.documentElement.style.setProperty(`--color-accent`,tr[e].color)},[e]);let n=(0,v.useCallback)(()=>{t(e=>{let t=rr(e+1);return ar(t),k.setAccent(t).catch(()=>{}),t})},[]),r=(0,v.useCallback)(e=>{t(t=>{let n=rr(e);return n===t?t:(ar(n),n)})},[]);return{accent:tr[e],next:tr[rr(e+1)],cycle:n,adopt:r}}function sr(e){return Number.isFinite(e)?Math.min(Math.max(e,20),85):55}function cr(e){return Math.round(sr(e))}function lr(e,t,n,r){let i=n-t;return sr(i<=0?r:(e-t)/i*100)}var ur=`nightcrow.upperPct`;function dr(){try{let e=Number(localStorage.getItem(ur));return Number.isFinite(e)&&e>0?cr(e):55}catch{return 55}}function fr(e){try{localStorage.setItem(ur,String(e))}catch{}}function pr(){let[e,t]=(0,v.useState)(dr);return{pct:e,resize:(0,v.useCallback)(e=>{t(sr(e))},[]),commit:(0,v.useCallback)(e=>{let n=cr(e);t(n),fr(n),k.setUpperPct(n).catch(()=>{})},[]),reset:(0,v.useCallback)(()=>{t(55),fr(55),k.setUpperPct(55).catch(()=>{})},[]),adopt:(0,v.useCallback)(e=>{t(t=>{let n=cr(e);return n===t?t:(fr(n),n)})},[])}}function mr(){let[e,t]=(0,v.useState)({}),n=(0,v.useRef)(e),r=(0,v.useCallback)(e=>{n.current=e,t(e)},[]),i=(0,v.useRef)(0),a=(0,v.useRef)(new Map),o=(0,v.useCallback)(e=>{let t=a.current.get(e);if(t)return t;let n=hn(t=>k.setMaximized(e,t===`none`?null:t));return a.current.set(e,n),n},[]),s=(0,v.useCallback)((e,t)=>{if(e==null)return;let a=n.current,s=typeof t==`function`?t(a[e]??`none`):t;i.current+=1,o(e)(s);let{[e]:c,...l}=a;r(s===`none`?l:{...a,[e]:s})},[o,r]);return{panelOf:(0,v.useCallback)(t=>t!=null&&e[t]||`none`,[e]),setFor:s,adopt:(0,v.useCallback)(e=>{hr(n.current,e)||r(e)},[r]),writes:i}}function hr(e,t){let n=Object.keys(e);return n.length===Object.keys(t).length&&n.every(n=>e[n]===t[n])}function gr(){let[e,t]=(0,v.useState)({}),n=(0,v.useRef)(e),r=(0,v.useCallback)(e=>{n.current=e,t(e)},[]),i=(0,v.useRef)(0),a=(0,v.useRef)(new Map),o=(0,v.useCallback)(e=>{let t=a.current.get(e);if(t)return t;let n=hn(t=>k.setRepoView(e,t));return a.current.set(e,n),n},[]),s=(0,v.useCallback)((e,t)=>{e!=null&&(Yn(n.current[e],t)||(i.current+=1,o(e)(t),r({...n.current,[e]:t})))},[o,r]),c=(0,v.useCallback)(t=>t==null?void 0:e[t],[e]),l=(0,v.useCallback)(e=>n.current[e],[]),u=(0,v.useRef)(new Set),[d,f]=(0,v.useState)(0);return{viewOf:c,rememberedFor:l,remember:s,adopt:(0,v.useCallback)((e,t)=>{let i=u.current;(i.size!==t.length||t.some(e=>!i.has(e)))&&(u.current=new Set(t),f(e=>e+1)),!_r(n.current,e)&&r(e)},[r]),covers:(0,v.useCallback)(e=>e!=null&&u.current.has(e),[d]),writes:i}}function _r(e,t){let n=Object.keys(e);return n.length===Object.keys(t).length&&n.every(n=>t[n]!==void 0&&Yn(e[n],t[n]))}function vr(){let{accent:e,next:t,cycle:n,adopt:r}=or(),{width:i,resize:a,commit:o,reset:s,adopt:c}=Me(),{pct:l,resize:u,commit:d,reset:f,adopt:p}=pr(),m=mr(),h=gr(),g=(0,v.useRef)(0),_=(0,v.useRef)(0),y=(0,v.useRef)(0);return{accent:e,next:t,cycle:(0,v.useCallback)(()=>{g.current+=1,n()},[n]),adoptAccent:r,accentWrites:g,sidebarWidth:i,resizeSidebar:a,commitSidebarWidth:(0,v.useCallback)(e=>{_.current+=1,o(e)},[o]),resetSidebarWidth:(0,v.useCallback)(()=>{_.current+=1,s()},[s]),bumpSidebarWrites:(0,v.useCallback)(()=>{_.current+=1},[]),adoptSidebarWidth:c,sidebarWrites:_,upperPct:l,resizeUpperPct:u,commitUpperPct:(0,v.useCallback)(e=>{y.current+=1,d(e)},[d]),resetUpperPct:(0,v.useCallback)(()=>{y.current+=1,f()},[f]),bumpUpperPctWrites:(0,v.useCallback)(()=>{y.current+=1},[]),adoptUpperPct:p,upperPctWrites:y,maximizedPanelOf:m.panelOf,setMaximizedFor:m.setFor,adoptMaximized:m.adopt,maximizedWrites:m.writes,viewOf:h.viewOf,rememberedViewFor:h.rememberedFor,rememberView:h.remember,adoptViews:h.adopt,viewCovers:h.covers,viewWrites:h.writes}}var yr=400;function br({value:e,valueAt:t,onGestureStart:n,resize:r,commit:i,reset:a,axis:o}){let s=(0,v.useRef)(0),c=(0,v.useRef)(0),l=(0,v.useRef)(!1),u=(0,v.useRef)(!1),d=(0,v.useRef)(0),[f,p]=(0,v.useState)(!1);return{dragging:f,onDragStart:(0,v.useCallback)(t=>{t.button!==0||!t.isPrimary||n()&&(s.current=o===`x`?t.clientX:t.clientY,c.current=e,l.current=!0,u.current=!1,p(!0),t.currentTarget.setPointerCapture(t.pointerId),t.preventDefault())},[e,n,o]),onDragMove:(0,v.useCallback)(e=>{if(!l.current)return;let n=o===`x`?e.clientX:e.clientY;if(!u.current&&Math.abs(n-s.current)<3)return;let i=t(e);i!==null&&(u.current=!0,c.current=i,r(i))},[t,r,o]),onDragEnd:(0,v.useCallback)(()=>{if(!l.current)return;if(l.current=!1,p(!1),u.current){i(c.current),d.current=0;return}let e=Date.now();e-d.current{l.current=!1,u.current=!1,d.current=0,p(!1)},[]),draggingRef:l}}function xr({sidebarRef:e,sidebarWidth:t,resizeSidebar:n,commitSidebarWidth:r,resetSidebarWidth:i,bumpSidebarWrites:a}){let o=(0,v.useRef)(0),s=(0,v.useCallback)(()=>{let t=e.current?.getBoundingClientRect().left;return t===void 0?!1:(o.current=t,a(),!0)},[e,a]),{dragging:c,onDragStart:l,onDragMove:u,onDragEnd:d,onDragCancel:f,draggingRef:p}=br({value:t,valueAt:(0,v.useCallback)(e=>e.clientX-o.current,[]),onGestureStart:s,resize:n,commit:r,reset:i,axis:`x`});return{draggingSidebar:c,onSidebarDragStart:l,onSidebarDragMove:u,onSidebarDragEnd:d,onSidebarDragCancel:f,draggingRef:p}}function Sr({upperRef:e,lowerRef:t,upperPct:n,resizeUpperPct:r,commitUpperPct:i,resetUpperPct:a,bumpUpperPctWrites:o}){let s=(0,v.useRef)(0),c=(0,v.useRef)(0),l=(0,v.useCallback)(()=>{let n=e.current?.getBoundingClientRect().top,r=t.current?.getBoundingClientRect().bottom;return n===void 0||r===void 0?!1:(s.current=n,c.current=r,o(),!0)},[e,t,o]),{dragging:u,onDragStart:d,onDragMove:f,onDragEnd:p,onDragCancel:m,draggingRef:h}=br({value:n,valueAt:(0,v.useCallback)(e=>lr(e.clientY,s.current,c.current,n),[n]),onGestureStart:l,resize:r,commit:i,reset:a,axis:`y`});return{draggingUpper:u,onUpperDragStart:d,onUpperDragMove:f,onUpperDragEnd:p,onUpperDragCancel:m,upperDraggingRef:h}}function Cr(){let{accent:e,next:t,cycle:n,adoptAccent:r,accentWrites:i,sidebarWidth:a,resizeSidebar:o,commitSidebarWidth:s,resetSidebarWidth:c,bumpSidebarWrites:l,adoptSidebarWidth:u,sidebarWrites:d,upperPct:f,resizeUpperPct:p,commitUpperPct:m,resetUpperPct:h,bumpUpperPctWrites:g,adoptUpperPct:_,upperPctWrites:y,maximizedPanelOf:b,setMaximizedFor:x,adoptMaximized:S,maximizedWrites:C,viewOf:w,rememberedViewFor:T,rememberView:E,adoptViews:D,viewCovers:O,viewWrites:ee}=vr(),k=(0,v.useRef)(null),te=(0,v.useRef)(null),ne=(0,v.useRef)(null),A=xr({sidebarRef:k,sidebarWidth:a,resizeSidebar:o,commitSidebarWidth:s,resetSidebarWidth:c,bumpSidebarWrites:l}),re=Sr({upperRef:te,lowerRef:ne,upperPct:f,resizeUpperPct:p,commitUpperPct:m,resetUpperPct:h,bumpUpperPctWrites:g});return{accent:e,next:t,cycle:n,upperPct:f,maximizedPanelOf:b,setMaximizedFor:x,viewOf:w,rememberedViewFor:T,rememberView:E,viewCovers:O,shell:{sidebarWidth:a,sidebarRef:k,upperRef:te,lowerRef:ne,draggingSidebar:A.draggingSidebar,onSidebarDragStart:A.onSidebarDragStart,onSidebarDragMove:A.onSidebarDragMove,onSidebarDragEnd:A.onSidebarDragEnd,onSidebarDragCancel:A.onSidebarDragCancel,draggingUpper:re.draggingUpper,onUpperDragStart:re.onUpperDragStart,onUpperDragMove:re.onUpperDragMove,onUpperDragEnd:re.onUpperDragEnd,onUpperDragCancel:re.onUpperDragCancel},guards:{adoptAccent:r,adoptSidebarWidth:u,adoptUpperPct:_,adoptMaximized:S,adoptViews:D,accentWrites:i,sidebarWrites:d,upperPctWrites:y,maximizedWrites:C,viewWrites:ee,draggingRef:A.draggingRef,upperDraggingRef:re.upperDraggingRef}}}function wr(){let[e,t]=(0,v.useState)(null),[n,r]=(0,v.useState)(!1),i=(0,v.useCallback)(e=>{if(x(e)){t(!1);return}N.error(e instanceof Error?e.message:`request failed`)},[]),a=er(),o=Cr(),s=Mn({authed:e,setAuthed:t,handle:i,resumeTick:a,...o.guards}),c=$n({project:{repo:s.repo,repos:s.repos,authed:e,hot:s.hot,clockSkewMs:s.clockSkewMs,resumeTick:a,handle:i},view:{known:o.viewCovers(s.repo),remembered:o.viewOf(s.repo),latest:o.rememberedViewFor,remember:o.rememberView},layout:{shell:o.shell,maximizedPanelOf:o.maximizedPanelOf,setMaximizedFor:o.setMaximizedFor}}),{selectOpenedRepo:l,closeRepo:u}=Pn({repo:s.repo,repos:s.repos,setRepos:s.setRepos,setRepo:s.setRepo,setPane:c.setPane,setTab:c.setTab,setPickerOpen:r,handle:i,orderWrites:s.orderWrites}),{busy:d,start:f}=pn(l,e===!0),p=(0,v.useCallback)(e=>{e!==s.repo&&(s.setRepo(e),c.clearPane())},[s.repo,s.setRepo,c.clearPane]),m=(0,v.useCallback)(()=>r(!0),[]),h=(0,v.useCallback)(()=>r(!1),[]);return{authed:e,login:(0,v.useCallback)(()=>t(null),[]),reposLoaded:s.reposLoaded,rows:ln(s.repo,c.maximized),upperPct:o.upperPct,header:{repos:s.repos,repo:s.repo,onSelectRepo:p,onCloseRepo:u,onOpenPicker:m,cloning:d,accent:o.accent,next:o.next,cycle:o.cycle,draggingRepo:s.draggingRepo,dragOverRepo:s.dragOverRepo,onRepoDragStart:s.onRepoDragStart,onRepoDragMove:s.onRepoDragMove,onRepoDragEnd:s.onRepoDragEnd},repoShell:c.repoShell,picker:n?{onClose:h,onOpened:l,canClone:s.canClone,cloning:d,onClone:f}:null}}function Tr(){let e=wr();return e.authed===null?(0,I.jsx)(we,{}):e.authed?e.reposLoaded?(0,I.jsxs)(`div`,{className:`nc-fade grid h-full ${e.rows}`,style:{"--nc-upper":`${e.upperPct}fr`,"--nc-lower":`${100-e.upperPct}fr`},children:[(0,I.jsx)(Ce,{...e.header}),e.repoShell?(0,I.jsx)(cn,{...e.repoShell}):(0,I.jsx)(`div`,{className:`flex items-center justify-center p-6 text-center text-ink-400`,children:(0,I.jsxs)(`span`,{children:[`No repository open. Click`,` `,(0,I.jsx)(`span`,{className:`text-ink-200`,children:`+ open`}),` above to add one.`]})}),e.picker&&(0,I.jsx)(pe,{...e.picker})]}):(0,I.jsx)(we,{}):(0,I.jsx)(Te,{onSuccess:e.login})}var Er={error:7e3,info:5e3,success:5e3},Dr={error:`text-removed`,info:`text-accent`,success:`text-added`};function Or(){let[e,t]=(0,v.useState)([]);return(0,v.useEffect)(()=>M(t),[]),e.length===0?null:(0,I.jsx)(`div`,{className:`pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2`,"aria-live":`polite`,children:e.map(e=>(0,I.jsx)(kr,{toast:e},e.id))})}function kr({toast:e}){let[t,n]=(0,v.useState)(!1);return(0,v.useEffect)(()=>{if(t||e.sticky)return;let n=setTimeout(()=>ae(e.id),Er[e.kind]);return()=>clearTimeout(n)},[e.id,e.kind,e.bump,e.sticky,t]),(0,I.jsxs)(`div`,{role:e.kind===`error`?`alert`:`status`,className:`nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg`,onMouseEnter:()=>n(!0),onMouseLeave:()=>n(!1),children:[(0,I.jsx)(`span`,{className:`min-w-0 flex-1 break-words ${Dr[e.kind]}`,children:e.message}),e.action&&(0,I.jsx)(`button`,{type:`button`,onClick:e.action.run,className:`shrink-0 rounded-sm border border-ink-700 px-1.5 py-0.5 text-ink-200 hover:border-accent hover:text-accent`,children:e.action.label}),(0,I.jsx)(`button`,{type:`button`,onClick:()=>ae(e.id),"aria-label":`dismiss`,className:`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200`,children:(0,I.jsx)(ce,{className:`h-3 w-3`})})]})}var Ar=`--nc-visual-viewport-height`;function jr(e){if(!e||!Number.isFinite(e.height)||e.height<=0)return null;let t=Number.isFinite(e.offsetTop)&&e.offsetTop>0?e.offsetTop:0;return e.height+t}function Mr(e,t){let n=t.visualViewport,r=()=>{let t=jr(n);t===null?e.style.removeProperty(Ar):e.style.setProperty(Ar,`${t}px`)};return r(),n?(n.addEventListener(`resize`,r),n.addEventListener(`scroll`,r),t.addEventListener(`resize`,r),()=>{n.removeEventListener(`resize`,r),n.removeEventListener(`scroll`,r),t.removeEventListener(`resize`,r),e.style.removeProperty(Ar)}):()=>void 0}Mr(document.documentElement,window),Tn(document.querySelector(`meta[name="nightcrow-build"]`)?.getAttribute(`content`)||null),(0,y.createRoot)(document.getElementById(`root`)).render((0,I.jsxs)(v.StrictMode,{children:[(0,I.jsx)(qt,{children:(0,I.jsx)(Tr,{})}),(0,I.jsx)(Or,{})]}));export{wt as a,le as c,ae as d,N as f,l as g,s as h,Ot as i,ce as l,o as m,gn as n,Tt as o,d as p,kt as r,Et as s,_n as t,F as u}; \ No newline at end of file diff --git a/viewer-ui/dist/assets/index-zJncWjjr.css b/viewer-ui/dist/assets/index-zJncWjjr.css new file mode 100644 index 00000000..9b9643bc --- /dev/null +++ b/viewer-ui/dist/assets/index-zJncWjjr.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.-top-px{top:-1px}.top-0{top:0}.top-1{top:var(--spacing)}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-1{right:var(--spacing)}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.left-1\/2{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-1{margin-block:calc(var(--spacing) * -1)}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:var(--spacing)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-11{min-height:calc(var(--spacing) * 11)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[50\%\]{max-width:50%}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-none{flex:none}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.cursor-row-resize{cursor:row-resize}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,1fr\)_auto_auto\]{grid-template-rows:auto minmax(0,1fr) auto auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-\[1ch\]{gap:1ch}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab, red, red)){.bg-added\/10{background-color:color-mix(in oklab, var(--color-added) 10%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-800{background-color:var(--color-ink-800)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab, red, red)){.bg-ink-900\/40{background-color:color-mix(in oklab, var(--color-ink-900) 40%, transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab, red, red)){.bg-removed\/10{background-color:color-mix(in oklab, var(--color-removed) 10%, transparent)}}.bg-white{background-color:var(--color-white)}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[1ch\]{padding-inline:1ch}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.opacity-60{opacity:.6}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media (hover:hover){.hover\:border-accent:hover{border-color:var(--color-accent)}.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab, red, red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-ink-700:active{background-color:var(--color-ink-700)}.active\:text-accent:active{color:var(--color-accent)}.disabled\:cursor-progress:disabled{cursor:progress}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}}@media (width>=40rem){.sm\:inline{display:inline}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:inline{display:inline}.md\:inline-flex{display:inline-flex}.md\:h-6{height:calc(var(--spacing) * 6)}.md\:w-6{width:calc(var(--spacing) * 6)}.md\:flex-1{flex:1}.md\:basis-1\/2{flex-basis:50%}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.md\:grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.md\:grid-rows-\[auto_minmax\(0\,var\(--nc-upper\)\)_minmax\(0\,var\(--nc-lower\)\)_auto\]{grid-template-rows:auto minmax(0,var(--nc-upper)) minmax(0,var(--nc-lower)) auto}.md\:flex-row{flex-direction:row}.md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;-webkit-text-decoration:overline double underline;-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;-webkit-text-decoration:overline wavy underline;-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;-webkit-text-decoration:overline dotted underline;-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;-webkit-text-decoration:overline dashed underline;-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}html,body,#root{height:var(--nc-visual-viewport-height,100%)}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/index.html b/viewer-ui/dist/index.html index 60310ced..07bfdbf8 100644 --- a/viewer-ui/dist/index.html +++ b/viewer-ui/dist/index.html @@ -14,8 +14,8 @@ nightcrow - - + +

diff --git a/viewer-ui/src/components/DiffView.tsx b/viewer-ui/src/components/DiffView.tsx index 84c3170a..9459c276 100644 --- a/viewer-ui/src/components/DiffView.tsx +++ b/viewer-ui/src/components/DiffView.tsx @@ -3,6 +3,8 @@ import { linenoDigits } from "../lib/gutter"; import { diffLineBg } from "../lib/utils"; import { LineNos } from "./LineNos"; import type { Diff, DiffLine } from "../api"; +import { VIRTUAL_THRESHOLD, type ScrollViewport } from "../lib/virtualWindow"; +import { VirtualDiffView } from "./VirtualDiffView"; function DiffLineContent({ line }: { line: DiffLine }) { return ( @@ -104,7 +106,28 @@ function SplitHunk({ lines, digits }: { lines: DiffLine[]; digits: number }) { ); } -export function DiffView({ diff, split }: { diff: Diff; split: boolean }) { +export function DiffView({ + diff, + split, + viewport = { scrollTop: 0, height: 600 }, +}: { + diff: Diff; + split: boolean; + viewport?: ScrollViewport; +}) { + const lineCount = diff.hunks.reduce((count, hunk) => count + hunk.lines.length, 0); + if (lineCount > VIRTUAL_THRESHOLD) { + return ( + <> + + {diff.truncated && ( +

+ Diff truncated — it exceeded the server's size ceiling. +

+ )} + + ); + } // One width for the whole diff, not per hunk: a gutter that resized at each // hunk boundary would step the code's left edge as you scrolled past one. const digits = linenoDigits(diff.hunks); diff --git a/viewer-ui/src/components/FileLines.tsx b/viewer-ui/src/components/FileLines.tsx new file mode 100644 index 00000000..5936e42d --- /dev/null +++ b/viewer-ui/src/components/FileLines.tsx @@ -0,0 +1,26 @@ +import type { Span } from "../api"; +import { digitsFor } from "../lib/gutter"; +import { LineNos } from "./LineNos"; + +/** Small files stay fully mounted so native selection and find remain exact. */ +export function FileLines({ lines }: { lines: Span[][] }) { + const digits = digitsFor(lines.length); + return ( +
+      {lines.map((line, index) => (
+        
+ + + {line.length === 0 + ? " " + : line.map((span, spanIndex) => ( + + {span.t} + + ))} + +
+ ))} +
+ ); +} diff --git a/viewer-ui/src/components/FilePane.tsx b/viewer-ui/src/components/FilePane.tsx index 9556ffd3..0317a662 100644 --- a/viewer-ui/src/components/FilePane.tsx +++ b/viewer-ui/src/components/FilePane.tsx @@ -1,8 +1,12 @@ import { Suspense, lazy, useEffect, useRef } from "react"; import { useDiffLayout } from "../lib/diffLayout"; import { fileViewSource, isHtmlPath, isPreviewablePath } from "../lib/fileView"; -import { digitsFor } from "../lib/gutter"; import { anchorWithin, hunkAtTop } from "../lib/diffAnchor"; +import { useScrollViewport } from "../hooks/ui/useScrollViewport"; +import { + lineScrollTop, + VIRTUAL_THRESHOLD, +} from "../lib/virtualWindow"; import { otherFace, sourceKey } from "../lib/otherFace"; import { MaximizeIcon, @@ -12,9 +16,10 @@ import { } from "./icons/layout"; import { DiffView } from "./DiffView"; import { ErrorBoundary } from "./feedback/ErrorBoundary"; -import { LineNos } from "./LineNos"; +import { FileLines } from "./FileLines"; +import { VirtualFileLines } from "./VirtualFileLines"; import { PathLabel } from "./PathLabel"; -import { api, type Span, type Status } from "../api"; +import { api, type Status } from "../api"; import type { FileSource, Pane } from "../types"; // Keep the markdown pipeline out of the initial chunk. @@ -25,30 +30,6 @@ const HtmlView = lazy(() => import("./content/Html").then((m) => ({ default: m.HtmlView })), ); -/// A file is numbered by its own lines, so the gutter carries one column and -/// no tint — the diff kinds have no meaning here. -function FileLines({ lines }: { lines: Span[][] }) { - const digits = digitsFor(lines.length); - return ( -
-      {lines.map((line, i) => (
-        
- - - {line.length === 0 - ? " " - : line.map((s, j) => ( - - {s.t} - - ))} - -
- ))} -
- ); -} - export interface FilePaneProps { /// The repository on screen. Part of what identifies a remembered scroll /// position: two projects can hold the same path, and this pane outlives a @@ -79,6 +60,7 @@ export function FilePane({ }: FilePaneProps) { const diffLayout = useDiffLayout(); const scroller = useRef(null); + const { viewport, refresh: refreshViewport } = useScrollViewport(scroller); const anchor = pane.kind === "file" ? pane.anchor : undefined; // Where the diff was left, so coming back from the file lands there. The two // faces share one scroller: without this the file's offset carries over and a @@ -98,9 +80,12 @@ export function FilePane({ const container = scroller.current; if (!container) return 0; const top = container.getBoundingClientRect().top; - const offsets = Array.from( + const rows = Array.from( container.querySelectorAll("[data-hunk]"), - (el) => el.getBoundingClientRect().top - top + container.scrollTop, + (el) => ({ + offset: el.getBoundingClientRect().top - top + container.scrollTop, + hunk: Number(el.dataset.hunk ?? 0), + }), ); if (pane.kind === "diff" && pane.source) { leftAt.current = { @@ -108,14 +93,15 @@ export function FilePane({ top: container.scrollTop, // Sideways as far as this container owns it, which is the unified // view — its rows are `w-max` inside this scroller. A split column - // scrolls itself (`overflow-x-auto` per half), and that offset is not - // kept: restoring it means addressing each column across a remount, for - // a position that matters far less than how far down the reader had - // got. + // scrolls itself, and that offset is not kept: restoring it means + // addressing each column across a remount, for a position that + // matters far less than how far down the reader had got. left: container.scrollLeft, }; } - return hunkAtTop(offsets, container.scrollTop); + const offsets = rows.map((row) => row.offset); + const renderedIndex = hunkAtTop(offsets, container.scrollTop); + return rows[renderedIndex]?.hunk ?? 0; }; // Put the anchored line at the top of the pane. Measured against the // scroller rather than `scrollIntoView`, which would also scroll whatever @@ -131,17 +117,24 @@ export function FilePane({ container.scrollTop = left.top; container.scrollLeft = left.left; leftAt.current = null; + refreshViewport(); } return; } if (anchor === undefined || pane.kind !== "file") return; const within = anchorWithin(anchor, pane.value.lines.length); if (within === null) return; + if (pane.value.lines.length > VIRTUAL_THRESHOLD) { + container.scrollTop = lineScrollTop(within); + refreshViewport(); + return; + } const line = container.querySelector(`[data-line="${within}"]`); if (!line) return; container.scrollTop += line.getBoundingClientRect().top - container.getBoundingClientRect().top; - }, [pane, anchor]); + refreshViewport(); + }, [pane, anchor, refreshViewport]); return (
@@ -217,7 +210,11 @@ export function FilePane({
-
+
{pane.kind === "empty" && (

{status === null ? "Loading…" : "Select a file or commit."} @@ -251,7 +248,11 @@ export function FilePane({ ) : ( - + pane.value.lines.length > VIRTUAL_THRESHOLD ? ( + + ) : ( + + ) )} {pane.value.truncated && (

@@ -261,7 +262,11 @@ export function FilePane({ )} {pane.kind === "diff" && ( - + )}

diff --git a/viewer-ui/src/components/LineNos.tsx b/viewer-ui/src/components/LineNos.tsx index 6c64e105..8049dbf0 100644 --- a/viewer-ui/src/components/LineNos.tsx +++ b/viewer-ui/src/components/LineNos.tsx @@ -15,6 +15,7 @@ export function LineNos({ nos, digits, tint = "", + stickyClass = "sticky left-0", }: { /// One entry per column; `undefined` leaves that column blank, which is what /// an added line's old side (and a removed line's new side) has to show. @@ -23,9 +24,11 @@ export function LineNos({ /// Row background to repeat over the opaque base, so the cell reads as part /// of its row rather than a notch of pane colour. tint?: string; + /** Split virtualization pins the new-side gutter at the second half. */ + stickyClass?: string; }) { return ( - + {nos.map((no, i) => ( diff --git a/viewer-ui/src/components/VirtualDiffView.tsx b/viewer-ui/src/components/VirtualDiffView.tsx new file mode 100644 index 00000000..99ee57e7 --- /dev/null +++ b/viewer-ui/src/components/VirtualDiffView.tsx @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Diff, DiffLine } from "../api"; +import { splitHunkRows } from "../lib/diffLayout"; +import { linenoDigits } from "../lib/gutter"; +import { diffLineBg } from "../lib/utils"; +import { virtualWindow, type ScrollViewport } from "../lib/virtualWindow"; +import { LineNos } from "./LineNos"; + +type Row = + | { kind: "header"; hunk: number; text: string } + | { kind: "unified"; hunk: number; line: DiffLine } + | { kind: "pair"; hunk: number; left: DiffLine | null; right: DiffLine | null } + | { kind: "side"; hunk: number; line: DiffLine | null; side: "old" | "new"; border: boolean }; + +function useWideSplit() { + const query = "(min-width: 768px)"; + const [wide, setWide] = useState(() => window.matchMedia(query).matches); + useEffect(() => { + const media = window.matchMedia(query); + const update = () => setWide(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return wide; +} + +function rowsFor(diff: Diff, split: boolean, wide: boolean): Row[] { + return diff.hunks.flatMap((hunk, hunkIndex) => { + const header: Row = { + kind: "header", + hunk: hunkIndex, + text: `${hunk.file_path ? `${hunk.file_path} ` : ""}${hunk.header}`, + }; + if (!split) { + return [ + header, + ...hunk.lines.map((line) => ({ + kind: "unified", + hunk: hunkIndex, + line, + })), + ]; + } + const pairs = splitHunkRows(hunk.lines); + if (wide) { + return [ + header, + ...pairs.map(({ left, right }) => ({ + kind: "pair", + hunk: hunkIndex, + left, + right, + })), + ]; + } + return [ + header, + ...pairs.map(({ left }) => ({ + kind: "side", + hunk: hunkIndex, + line: left, + side: "old", + border: false, + })), + ...pairs.map(({ right }, index) => ({ + kind: "side", + hunk: hunkIndex, + line: right, + side: "new", + border: index === 0, + })), + ]; + }); +} + +function Content({ line }: { line: DiffLine }) { + return ( + + {line.kind} + {line.spans.map((span, index) => ( + {span.t} + ))} + + ); +} + +function Cell({ line, side, digits, stickyClass }: { line: DiffLine | null; side: "old" | "new"; digits: number; stickyClass?: string }) { + const tint = line ? diffLineBg(line.kind) : "bg-ink-900/40"; + return ( +
+ + {line ? : } +
+ ); +} + +export function VirtualDiffView({ diff, split, viewport }: { diff: Diff; split: boolean; viewport: ScrollViewport }) { + const wide = useWideSplit(); + const rows = useMemo(() => rowsFor(diff, split, wide), [diff, split, wide]); + const digits = linenoDigits(diff.hunks); + const range = virtualWindow(rows.length, viewport.scrollTop, viewport.height); + return ( +
+