Git-aware read-only file viewer: tree, diffs, rendered markdown, fuzzy find

A git-aware, read-only file viewer for Claude Code: /files [path[:line]] opens a pane with the repository tree on the left and the file's content on the right — a diff when it changed, rendered markdown for .md, highlighted code otherwise. Behaviour follows ARCHITECTURE.md §5; the mods API reference is docs/claude-code-mods-api.md.
claude update./files), and withdraws itself if the engine reports isPlaced: false.node scripts/sync-core.mjs # copy core/src → hooks/core (core is the source of truth)
claude --plugin-dir ./adapters/claude-code
then type /files (optionally /files src/x.ts:42) inside a repository.
.claude-plugin/plugin.json manifest: name, version, description
hooks/hooks.json {"description": "git-aware file viewer", "modules": ["./register.ts"]}
hooks/register.ts register(on, options): every hook, the only file that touches $
hooks/model.ts module-level model, refresh/selection/actions, $.store preferences
hooks/pane.ts pure drawing: model + rows + size → element tree
hooks/host.ts core Host over the Engine closures ($.fs / $.process.run + SAFE git flags)
hooks/core/ sync-core output — never hand-edit; rerun the script instead
tests/*.test.ts claude plugin test suite
skills/file-viewer/SKILL.md teaches the agent how to open and drive the pane
register.ts builds one Engine object of closures over $ (engineOf) and hands that object to model.ts / host.ts. The validation rules allow $ to be passed only to a top-level function of the same file, so nothing else ever sees it.
| Key | Button | Action | ||
|---|---|---|---|---|
f | f: find | Fuzzy-find a file (Input, autoFocus); Enter selects the best match | ||
v | v: view | Cycle diff → fullDiff → code/markdown for the selected file | ||
n | n: next | Next changed file | ||
p | p: prev | Previous changed file | ||
b | b: HEAD / b: Base | Flip the diff baseline; saved per repository | ||
c | c: changed / c: all files | Changed-only filter; saved per repository | ||
l | `l: tree\ | file / l: file\ | tree` | Swap the split layout (tree ↔ content sides); saved per repository |
r | r: reload | Refresh git status + tree now | ||
Tab ↑ ↓ | — | Walk controls; the tree window follows the focus ring (ui.focus) | ||
wheel, PageUp/PageDown | — | Pointer over the tree moves the tree cursor; over the content it scrolls the file (ui.scroll answered by the mod) | ||
Esc | — | Closes the pane (closeOnEscape: true) |
Tree row labels are ❯ M src/x.ts: selection mark, git mark (M/A/D/?), indentation, expansion caret for folders, name; a folder with a changed file underneath ends in ~.
redraw() coalesces redraws through $.clock.after(120) → $.ui.invalidate('ui.render'); ui.render{Pane} rebuilds the whole element tree from the model (single direction of data flow, as in the built-in diff mod).session.start (initial load), command.run{files} (awaited before $.ui.open, so the pane never lands on a loading state), and tool.call{Edit,Write,NotebookEdit,Bash,PowerShell} — scheduleRefresh() is debounced and serialised with isRefreshing / isRefreshQueued.git rev-parse --show-toplevel from the session cwd, falling back to the cwd when the session is not in a repository. Core paths are repo-root-relative POSIX; host.ts turns them into absolute paths for $.fs, so session.cwd() ≠ repo root needs no special case.['git', ...SAFE_GIT_FLAGS(isWindows), ...args] with env: SAFE_GIT_ENV (GIT_OPTIONAL_LOCKS=0), no shell, argv arrays only — $.process.run(argv, { cwd, env }) accepts both.isWindows is derived from the session cwd (C:/… or backslashes); SAFE_GIT_FLAGS then uses core.hooksPath=NUL.baseline, changedOnly and the changed-file view preference live in $.store under file-viewer:<repo-root>:<name>.ui.render carries a .catch that draws a one-line notice, and every gating hook has one (required by claude plugin validate --strict).node scripts/sync-core.mjs # after any core/ change
claude plugin validate --strict ./adapters/claude-code
claude plugin test ./adapters/claude-code # 14 tests, no sign-in needed
claude --plugin-dir ./adapters/claude-code # load + hot reload
# in-session: /reload-plugins
n/p stand in for ]/[: a mod hotkey must be one digit or one lowercase letter, so ] and [ cannot be bound. v is a cycle, not a toggle; the changed-file view preference is persisted but has no key yet.↑/↓ moving the focus instead of scrolling the pane away from the toolbar), so long files are read a screen at a time; the header shows L<first>-<last>/<total>.… [truncated]); a markdown line wider than its column wraps and can push the drawing one row past the pane.DEFAULT_TREE_OPTIONS.hideHidden); there is no toggle, so /files .github/… reports hidden path.binary file./cd into another directory does not re-root it (start a new session or /files again after moving).hooks/register.ts 139 lines1import type { EngineInterface, On } from 'claude-code';
2import { parseFileRef } from './core/types.ts';
3import { isWindowsPath, type Engine } from './host.ts';
4import { PANE_ID, PANE_TITLE, renderPane, type PaneElements } from './pane.ts';
5import {
6 actions,
7 bindEngine,
8 ensureContent,
9 focusRow,
10 handleScroll,
11 model,
12 noteSize,
13 openTarget,
14 refresh,
15 resetSession,
16 scheduleRefresh,
17 setFocusSide,
18 setShown,
19} from './model.ts';
20
21const COMMAND = 'files';
22
23let engine: Engine | null = null;
24
25function engineOf($: EngineInterface, sessionCwd: string): Engine {
26 return {
27 cwd: () => sessionCwd,
28 isWindows: isWindowsPath(sessionCwd),
29 list: (path) => $.fs.list(path),
30 read: (path) => $.fs.read(path),
31 exists: (path) => $.fs.exists(path),
32 run: (argv, init) => $.process.run(argv, init),
33 after: (ms, fn) => $.clock.after(ms, fn),
34 storeGet: (key) => $.store.get(key),
35 storeSet: (key, value) => $.store.set(key, value),
36 invalidate: () => $.ui.invalidate('ui.render'),
37 };
38}
39
40async function boot($: EngineInterface): Promise<Engine> {
41 if (engine !== null) return engine;
42 const sessionCwd = await $.session.cwd();
43 engine = engineOf($, sessionCwd);
44 bindEngine(engine, sessionCwd);
45 return engine;
46}
47
48/**
49 * The file viewer mod: `/files [path[:line]]` opens the pane, the pane draws
50 * from `model.ts`'s single state object, and every edit or shell command
51 * schedules a refresh of the tree and the open file.
52 */
53export function register(on: On, options?: Record<string, unknown>): void {
54
55 on('session.start', async ($, e, next) => {
56 resetSession(e.cwd);
57 engine = engineOf($, e.cwd);
58 bindEngine(engine, e.cwd);
59 try {
60 await $.command.register({
61 name: COMMAND,
62 description: 'Open the git-aware file viewer (tree, diff, markdown, code)',
63 argumentHint: '[path[:line]]',
64 immediate: true,
65 });
66 } catch {
67 // Another plugin already owns /files; leave its registration standing.
68 }
69 scheduleRefresh();
70 return next(e);
71 });
72
73 on('command.run', { command: COMMAND }, async ($, e) => {
74 await boot($);
75 const ref = parseFileRef(e.args);
76 await refresh();
77 if (ref.path !== '') await openTarget(ref);
78 const opened = (await $.ui.open({
79 id: PANE_ID,
80 title: PANE_TITLE,
81 focus: true,
82 closeOnEscape: true,
83 })) as { isPlaced?: boolean } | undefined;
84 if (opened?.isPlaced === false) {
85 await $.ui.close({ id: PANE_ID });
86 return { text: 'Files: the pane is waiting for a wider terminal.' };
87 }
88 setShown(true);
89 return {};
90 }).catch(async () => ({ text: 'file viewer: could not open the pane' }));
91
92 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
93 if (e.requestId !== PANE_ID) return next(e);
94 await boot($);
95 setShown(true);
96 const elements = (await $.ui.resolve(e)) as unknown as PaneElements;
97 const size = { bodyColumns: e.props.bodyColumns, bodyRows: e.props.scroll.bodyRows };
98 noteSize(size);
99 ensureContent();
100 return renderPane(elements, model(), size, actions);
101 }).catch(async ($, e) => {
102 const elements = (await $.ui.resolve(e)) as unknown as PaneElements;
103 return elements.Box({
104 children: [elements.Text({ children: ['file viewer: this drawing failed'] })],
105 });
106 });
107
108 on('ui.focus', { component: 'Pane', requestId: PANE_ID }, ($, e, next) => {
109 const key = e.element;
110 if (typeof key === 'string' && key.startsWith('row:')) {
111 setFocusSide('tree');
112 focusRow(key.slice(4));
113 } else if (typeof key === 'string') {
114 setFocusSide('toolbar');
115 }
116 return next(e);
117 }).catch(($, e, next) => next(e));
118
119 on('ui.scroll', { requestId: PANE_ID }, ($, e) => {
120 handleScroll(e.by, e.bodyRows, e.pointer?.column);
121 return {};
122 }).catch(($, e, next) => next(e));
123
124 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
125 const result = await next(e);
126 setShown(false);
127 return result;
128 }).catch(($, e, next) => next(e));
129
130 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'] }, async ($, e, next) => {
131 try {
132 return await next(e);
133 } finally {
134 scheduleRefresh();
135 }
136 }).catch(($, e, next) => next(e));
137}
138
139hooks/core/types.ts 107 lines1/**
2 * Platform-agnostic contracts for the file viewer.
3 * Ported from herdr-file-viewer (Rust) — see ARCHITECTURE.md for the mapping.
4 *
5 * Rules:
6 * - All paths are repo-root-relative, POSIX-style ('src/main.rs'), except where noted.
7 * - Pure data only: no I/O, no classes with behavior beyond data.
8 * - Adapters (claude-code, codex, opencode, deepseek-harness) import these types;
9 * they must never be changed without updating ARCHITECTURE.md.
10 */
11
12/** A file's git status against the working tree (git.rs: Status). */
13export type GitStatus = 'Modified' | 'Added' | 'Deleted' | 'Untracked';
14
15/** What a diff and "changed" compare against (git.rs: Baseline). */
16export type Baseline = 'HEAD' | 'Base';
17
18/** Whether a tree node is a directory or a file (tree.rs: NodeKind). */
19export type NodeKind = 'dir' | 'file';
20
21/** One visible row of the tree (tree.rs: Node). */
22export interface TreeNode {
23 /** Repo-root-relative path, POSIX separators. For compacted chains, the deepest dir. */
24 path: string;
25 kind: NodeKind;
26 depth: number;
27 expanded: boolean;
28 /** Per-file git status for tree markers; undefined = clean. */
29 status?: GitStatus;
30 /** Dir only: any file under it has a git status. Always false for files. */
31 dirDirty: boolean;
32 /** Display override when not the path's final component (compacted chain label). */
33 label?: string;
34}
35
36/** The facts view policy needs about a file — no I/O (view_policy.rs: FileDescriptor). */
37export interface FileDescriptor {
38 /** Repo-root-relative path. */
39 path: string;
40 isMarkdown: boolean;
41 isChanged: boolean;
42 /** Cached git status says deleted → no content to render. */
43 isDeleted: boolean;
44}
45
46/** Which rendering the content pane shows (view_policy.rs: ViewMode). */
47export type ViewMode = 'renderedMarkdown' | 'diff' | 'fullDiff' | 'syntaxContent';
48
49/** Which policy changed files use for their automatic initial view (ChangedFileView). */
50export type ChangedFileView = 'diff' | 'content';
51
52/** A directory listing entry produced by Host.listDir. */
53export interface DirEntry {
54 /** Entry name within its directory (no separator). */
55 name: string;
56 kind: NodeKind;
57 /** Present for files when the host knows it. */
58 size?: number;
59}
60
61/** Result of a git invocation; degraded to exitCode -1 when git is unavailable. */
62export interface RunResult {
63 exitCode: number;
64 stdout: string;
65 stderr: string;
66}
67
68/**
69 * The I/O surface core logic needs. Adapters implement this on top of their host
70 * (claude-code: $.fs / $.process.run; codex/opencode: node:fs / child_process;
71 * dsh: fs + child_process). Core never imports node:fs or spawns processes itself.
72 */
73export interface Host {
74 /** Session working directory (absolute). */
75 cwd(): string;
76 /** true on Windows — drives null-device and path handling. */
77 isWindows: boolean;
78 /** List one directory level. Relative paths resolve against cwd(). Missing dir → []. */
79 listDir(relPath: string): Promise<DirEntry[]>;
80 /** Read a UTF-8 text file (adapters cap at 4 MiB; oversized → throw). */
81 readText(relPath: string): Promise<string>;
82 /** True when the path exists (file or dir). */
83 exists(relPath: string): Promise<boolean>;
84 /**
85 * Run a read-only git command in the given absolute dir, e.g.
86 * runGit(dir, ['status','--porcelain=v1','-z','-uall']).
87 * Adapters MUST pass git safety flags (see git.ts SAFE_GIT_FLAGS) and MUST NOT
88 * use a shell. Failure/non-repo → { exitCode: -1, stdout: '', stderr }.
89 * Timeout: 30s default.
90 */
91 runGit(dir: string, args: string[]): Promise<RunResult>;
92}
93
94/** A parsed `path:line` reference (for opening files at a line). */
95export interface FileRef {
96 path: string;
97 line?: number;
98}
99
100/** Parse "src/app.rs:42" / "src/app.rs" → FileRef. Pure. */
101export function parseFileRef(input: string): FileRef {
102 const m = /^(.*?)(?::(\d+))?$/.exec(input.trim())!;
103 const path = m[1] ?? '';
104 const line = m[2] ? Number(m[2]) : undefined;
105 return line === undefined ? { path } : { path, line };
106}
107hooks/host.ts 95 lines1import type { DirEntry, Host, RunResult } from './core/types.ts';
2import { SAFE_GIT_ENV, SAFE_GIT_FLAGS } from './core/git.ts';
3
4/** One `$.fs.list` entry, in the shape the mods API answers with. */
5export interface FsEntryLike {
6 name: string;
7 kind: 'file' | 'dir' | 'other';
8 size: number;
9 isLink: boolean;
10}
11
12/**
13 * The mods API, reached through closures `register.ts` builds over `$`.
14 * Hooks and timers hand this object around; only `register.ts` may touch `$`,
15 * which is why nothing here takes `$` as an argument.
16 */
17export interface Engine {
18 cwd(): string;
19 isWindows: boolean;
20 list(path: string): Promise<FsEntryLike[]>;
21 read(path: string): Promise<string>;
22 exists(path: string): Promise<boolean>;
23 run(argv: string[], init?: { cwd?: string; env?: Record<string, string> }): Promise<RunResult>;
24 after(ms: number, fn: () => void): { cancel(): void };
25 storeGet(key: string): Promise<unknown>;
26 storeSet(key: string, value: unknown): Promise<void>;
27 invalidate(): void;
28}
29
30/** True for `C:/x`, `C:\x` or a UNC path — drives `SAFE_GIT_FLAGS`. */
31export function isWindowsPath(path: string): boolean {
32 return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
33}
34
35function errorText(err: unknown): string {
36 if (err instanceof Error) return err.message;
37 return String(err);
38}
39
40/**
41 * The core `Host` over an `Engine`. Core paths are repo-root-relative and
42 * POSIX; this is where they become absolute paths for `$.fs`, and where every
43 * git call picks up `SAFE_GIT_FLAGS` / `SAFE_GIT_ENV`.
44 *
45 * `root` is the repo root (or the session cwd outside a repository).
46 */
47export function createHost(engine: Engine, root: string): Host {
48 const base = root.replace(/\/+$/, '');
49 const abs = (relPath: string): string => {
50 const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
51 return rel === '' || rel === '.' ? base : `${base}/${rel}`;
52 };
53
54 return {
55 cwd: () => engine.cwd(),
56 isWindows: engine.isWindows,
57
58 async listDir(relPath: string): Promise<DirEntry[]> {
59 try {
60 const entries = await engine.list(abs(relPath));
61 return entries.map((entry) => ({
62 name: entry.name,
63 kind: entry.kind === 'dir' ? ('dir' as const) : ('file' as const),
64 size: entry.size,
65 }));
66 } catch {
67 return [];
68 }
69 },
70
71 async readText(relPath: string): Promise<string> {
72 return engine.read(abs(relPath));
73 },
74
75 async exists(relPath: string): Promise<boolean> {
76 try {
77 return await engine.exists(abs(relPath));
78 } catch {
79 return false;
80 }
81 },
82
83 async runGit(dir: string, args: string[]): Promise<RunResult> {
84 try {
85 return await engine.run(['git', ...SAFE_GIT_FLAGS(engine.isWindows), ...args], {
86 cwd: dir,
87 env: SAFE_GIT_ENV,
88 });
89 } catch (err) {
90 return { exitCode: -1, stdout: '', stderr: errorText(err) };
91 }
92 },
93 };
94}
95hooks/pane.ts 498 lines1import type { RenderElement } from 'claude-code';
2import type {
3 Baseline,
4 ChangedFileView,
5 FileDescriptor,
6 NodeKind,
7 TreeNode,
8 ViewMode,
9} from './core/types.ts';
10import { applicableModes, defaultMode } from './core/view_policy.ts';
11
12/** The pane's id: `$.ui.open`, `ui.render`, `ui.scroll` and `ui.focus` all key on it. */
13export const PANE_ID = 'file-viewer';
14
15/** The pane's tab title. */
16export const PANE_TITLE = 'Files';
17
18/** The hard cap `Code.source` and `Markdown.text` impose on one element. */
19export const TEXT_LIMIT = 10000;
20
21/** One drawing pass: everything the pane knows about its own geometry. */
22export interface PaneSize {
23 bodyColumns: number;
24 bodyRows: number;
25}
26
27/** The rows and columns each part of the pane may take. */
28export interface Layout {
29 treeColumns: number;
30 treeRows: number;
31 treeWindowRows: number;
32 contentRows: number;
33}
34
35/** Which side of the split the tree sits on; the content takes the other. */
36export type LayoutSide = 'tree-left' | 'tree-right';
37
38/** The data the drawing reads. `model.ts` owns the instance. */
39export interface Model {
40 rows: readonly TreeNode[];
41 selectedPath?: string;
42 selectedKind?: NodeKind;
43 treeStart: number;
44 contentStart: number;
45 contentLines: readonly string[];
46 contentKind: 'none' | 'source' | 'diff';
47 contentPath?: string;
48 notice?: string;
49 viewMode: ViewMode;
50 baseline: Baseline;
51 changedView: ChangedFileView;
52 changedOnly: boolean;
53 layoutSide: LayoutSide;
54 isRepo: boolean;
55 isFindOpen: boolean;
56 findQuery: string;
57 status: string;
58 /** The tree root's basename — the tree column's header line (herdr border title). */
59 rootName: string;
60 /** Current git branch; undefined outside a repo / on a detached HEAD. */
61 branch?: string;
62 /** Which region was touched last (herdr focus → border highlight). */
63 focusSide: 'tree' | 'content' | 'toolbar';
64}
65
66/** The callbacks the drawing wires to its controls. */
67export interface PaneActions {
68 onRowPress(path: string, kind: NodeKind): void;
69 onToggleFind(): void;
70 onInputFind(value: string): void;
71 onSubmitFind(value: string): void;
72 onCycleView(): void;
73 onNextChanged(): void;
74 onPrevChanged(): void;
75 onCycleBaseline(): void;
76 onToggleChangedOnly(): void;
77 onToggleLayout(): void;
78 onRefresh(): void;
79}
80
81type El = (props: any) => RenderElement;
82
83/** The elements `$.ui.resolve(e)` hands over, narrowed to what the pane draws. */
84export interface PaneElements {
85 Box: El;
86 Text: El;
87 Button: El;
88 Input: El;
89 Code: El;
90 Markdown: El;
91}
92
93const TOOLBAR_ROWS = 1;
94const STATUS_ROWS = 1;
95const BORDER_ROWS = 2;
96/** Rows the tree/content columns need beyond border + their header line. */
97const MIN_SIDE_ROWS = 4;
98/** Border colours (herdr border_style): focused accent vs idle dim. */
99const FOCUS_BORDER = 'promptBorder';
100const IDLE_BORDER = 'inactive';
101
102export function clamp(value: number, low: number, high: number): number {
103 return value < low ? low : value > high ? high : value;
104}
105
106/** How tall and wide each pane region may be for one `ui.render`. */
107export function layoutOf(size: PaneSize): Layout {
108 const sideRows = Math.max(MIN_SIDE_ROWS, size.bodyRows - TOOLBAR_ROWS - STATUS_ROWS);
109 const treeRows = sideRows;
110 // Border (2) + the root-name header line the tree column now draws inside it.
111 const treeWindowRows = Math.max(1, treeRows - BORDER_ROWS - 1);
112 // The content column borders itself too: border (2) + the file header line.
113 const contentRows = Math.max(1, sideRows - BORDER_ROWS - 1);
114 let treeColumns = Math.round(size.bodyColumns * 0.3);
115 treeColumns = clamp(treeColumns, 16, 44);
116 treeColumns = Math.min(treeColumns, Math.max(12, size.bodyColumns - 24));
117 return { treeColumns, treeRows, treeWindowRows, contentRows };
118}
119
120function baseName(path: string): string {
121 const at = path.lastIndexOf('/');
122 return at === -1 ? path : path.slice(at + 1);
123}
124
125/**
126 * One-character git marker (herdr presenter status_marker): files carry their
127 * status letter; a directory containing a change carries `●` (was a `~` name
128 * suffix — herdr puts the glyph in its own column so it survives color loss).
129 */
130export function statusMark(row: TreeNode): string {
131 if (row.kind === 'dir') return row.dirDirty ? '●' : ' ';
132 switch (row.status) {
133 case 'Modified':
134 return 'M';
135 case 'Added':
136 return 'A';
137 case 'Deleted':
138 return 'D';
139 case 'Untracked':
140 return '?';
141 default:
142 return ' ';
143 }
144}
145
146/**
147 * Marker-cell colour (herdr row_color): modified/deleted and dirty dirs red,
148 * added/untracked green — theme keys so the person's palette wins.
149 */
150export function markerColor(row: TreeNode): string | undefined {
151 if (row.kind === 'dir') return row.dirDirty ? 'error' : undefined;
152 switch (row.status) {
153 case 'Modified':
154 case 'Deleted':
155 return 'error';
156 case 'Added':
157 case 'Untracked':
158 return 'success';
159 default:
160 return undefined;
161 }
162}
163
164/** The pressable marker cell: selection slot + status glyph + padding (4 columns). */
165export function markerCell(row: TreeNode, isSelected: boolean): string {
166 return (isSelected ? '❯ ' : ' ') + statusMark(row) + ' ';
167}
168
169/**
170 * The name cell: 2-space-per-depth indent, expansion caret (files reserve the
171 * caret's width too — herdr #130, so siblings at one depth line up), name.
172 */
173export function rowName(row: TreeNode): string {
174 const indent = ' '.repeat(Math.max(0, row.depth));
175 const caret = row.kind === 'dir' ? (row.expanded ? '▾ ' : '▸ ') : ' ';
176 return indent + caret + (row.label ?? baseName(row.path));
177}
178
179/** The whole row as one string (tests + opencode's byte-compatible copy). */
180export function rowLabel(row: TreeNode, isSelected: boolean): string {
181 return markerCell(row, isSelected) + rowName(row);
182}
183
184/** Trim text to the element cap, leaving the engine's truncation note at the tail. */
185export function fitText(text: string, limit = TEXT_LIMIT): string {
186 if (text.length <= limit) return text;
187 const note = '\n… [truncated]';
188 return text.slice(0, Math.max(0, limit - note.length)) + note;
189}
190
191/** The view a file opens in, honouring a caller's override when the policy allows it. */
192export function resolveMode(
193 fd: FileDescriptor,
194 pref: ChangedFileView,
195 override?: ViewMode,
196): ViewMode {
197 if (fd.isDeleted) return 'diff';
198 const modes = applicableModes(fd, pref);
199 if (override !== undefined && modes.includes(override)) return override;
200 return defaultMode(fd, pref);
201}
202
203/** The next view in the file's own cycle (`v`). */
204export function cycleMode(fd: FileDescriptor, pref: ChangedFileView, current: ViewMode): ViewMode {
205 const modes = fd.isDeleted
206 ? applicableModes(fd, pref).filter((mode) => mode === 'diff' || mode === 'fullDiff')
207 : applicableModes(fd, pref);
208 if (modes.length === 0) return 'diff';
209 const at = modes.indexOf(current);
210 if (at === -1) return modes[0] as ViewMode;
211 return modes[(at + 1) % modes.length] as ViewMode;
212}
213
214const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
215
216function isFileHeader(line: string): boolean {
217 return (
218 line.startsWith('diff --git') ||
219 line.startsWith('index ') ||
220 line.startsWith('--- ') ||
221 line.startsWith('+++ ') ||
222 line.startsWith('new file') ||
223 line.startsWith('deleted file') ||
224 line.startsWith('old mode') ||
225 line.startsWith('new mode') ||
226 line.startsWith('similarity index') ||
227 line.startsWith('rename ') ||
228 line.startsWith('Binary files') ||
229 line.startsWith('\\ No newline')
230 );
231}
232
233function isHunkHeader(line: string): boolean {
234 return HUNK_RE.test(line);
235}
236
237function isHeaderLine(line: string): boolean {
238 return isFileHeader(line) || isHunkHeader(line);
239}
240
241/** True when the text parses as unified-diff hunks, as `Code({ format: 'diff' })` needs. */
242export function looksLikeDiff(text: string): boolean {
243 if (!text.includes('@@')) return false;
244 let hunks = 0;
245 for (const line of text.split('\n')) {
246 if (isHunkHeader(line)) {
247 hunks += 1;
248 continue;
249 }
250 if (line === '' || isFileHeader(line)) continue;
251 if (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-')) continue;
252 return false;
253 }
254 return hunks > 0;
255}
256
257function countLine(line: string, counts: { ctx: number; add: number; del: number }): void {
258 if (line.startsWith(' ')) counts.ctx += 1;
259 else if (line.startsWith('+')) counts.add += 1;
260 else if (line.startsWith('-')) counts.del += 1;
261 else if (line === '') counts.ctx += 1;
262}
263
264function hunkHeader(srcStart: number, tgtStart: number, counts: { ctx: number; add: number; del: number }): string {
265 return `@@ -${srcStart},${counts.ctx + counts.del} +${tgtStart},${counts.ctx + counts.add} @@`;
266}
267
268/**
269 * Cut `lines[from, to)` as a diff that still parses: the window's first hunk
270 * header is rewritten to the line numbers and counts the slice really holds, so
271 * scrolling a diff can land anywhere without `Code({ format: 'diff' })` refusing.
272 */
273export function sliceDiff(lines: readonly string[], from: number, to: number): string {
274 const start = clamp(from, 0, lines.length);
275 const end = clamp(to, start, lines.length);
276 if (start === 0) return lines.slice(0, end).join('\n');
277
278 const out: string[] = [];
279 let i = start;
280 while (i < end) {
281 const line = lines[i] as string;
282 if (isHunkHeader(line)) {
283 const match = HUNK_RE.exec(line);
284 const counts = { ctx: 0, add: 0, del: 0 };
285 let j = i + 1;
286 while (j < end && !isHeaderLine(lines[j] as string)) {
287 countLine(lines[j] as string, counts);
288 j += 1;
289 }
290 out.push(hunkHeader(Number(match?.[1] ?? 1), Number(match?.[2] ?? 1), counts));
291 for (let k = i + 1; k < j; k += 1) out.push(lines[k] as string);
292 i = j;
293 continue;
294 }
295 if (isFileHeader(line)) {
296 out.push(line);
297 i += 1;
298 continue;
299 }
300 let governing = -1;
301 for (let k = start - 1; k >= 0; k -= 1) {
302 if (isHunkHeader(lines[k] as string)) {
303 governing = k;
304 break;
305 }
306 }
307 if (governing === -1) {
308 for (let k = start; k < end; k += 1) out.push(lines[k] as string);
309 break;
310 }
311 const match = HUNK_RE.exec(lines[governing] as string);
312 const before = { ctx: 0, add: 0, del: 0 };
313 for (let k = governing + 1; k < start; k += 1) countLine(lines[k] as string, before);
314 const counts = { ctx: 0, add: 0, del: 0 };
315 let j = start;
316 while (j < end && !isHeaderLine(lines[j] as string)) {
317 countLine(lines[j] as string, counts);
318 j += 1;
319 }
320 out.push(
321 hunkHeader(
322 Number(match?.[1] ?? 1) + before.ctx + before.del,
323 Number(match?.[2] ?? 1) + before.ctx + before.add,
324 counts,
325 ),
326 );
327 for (let k = start; k < j; k += 1) out.push(lines[k] as string);
328 i = j;
329 }
330 return out.join('\n');
331}
332
333function modeLabel(mode: ViewMode): string {
334 switch (mode) {
335 case 'diff':
336 return 'diff';
337 case 'fullDiff':
338 return 'full diff';
339 case 'renderedMarkdown':
340 return 'markdown';
341 default:
342 return 'code';
343 }
344}
345
346function headerText(m: Model, from: number, to: number): string {
347 const parts = [m.contentPath ?? '(nothing selected)', modeLabel(m.viewMode)];
348 if (m.contentKind !== 'none') {
349 const total = m.contentLines.length;
350 parts.push(m.contentKind === 'diff' ? `${from + 1}-${to}/${total}` : `L${from + 1}-${to}/${total}`);
351 if (m.contentKind === 'diff') parts.push(`vs ${m.baseline}`);
352 }
353 return parts.join(' · ');
354}
355
356function statusText(m: Model, first: number, last: number): string {
357 const parts: string[] = [];
358 if (m.rows.length === 0) parts.push('0 rows');
359 else parts.push(`${first}-${last}/${m.rows.length} rows`);
360 parts.push(m.isRepo ? `base ${m.baseline}` : 'no git repo');
361 // herdr tree bottom border: the current branch (omitted when detached/no repo).
362 if (m.branch !== undefined && m.branch !== '') parts.push(m.branch);
363 if (m.changedOnly) parts.push('changed only');
364 if (m.status !== '') parts.push(m.status);
365 return parts.join(' · ');
366}
367
368function contentBody(ui: PaneElements, m: Model, from: number, to: number): RenderElement {
369 if (m.contentKind === 'none') {
370 const props: Record<string, unknown> = { children: [m.notice ?? ''], wrap: 'truncate-end' };
371 if (m.notice !== undefined) props.color = 'warning'; // herdr notices strip
372 else props.dimColor = true;
373 return ui.Text(props);
374 }
375 const visible = m.contentLines.slice(from, to);
376 if (m.contentKind === 'diff') {
377 const text = fitText(sliceDiff(m.contentLines, from, to));
378 return looksLikeDiff(text)
379 ? ui.Code({ source: text, format: 'diff', path: m.contentPath, wrap: 'truncate-end' })
380 : ui.Code({ source: text, format: 'source', path: m.contentPath, startLine: from + 1, wrap: 'truncate-end' });
381 }
382 const text = fitText(visible.join('\n'));
383 if (m.viewMode === 'renderedMarkdown') return ui.Markdown({ text });
384 return ui.Code({ source: text, path: m.contentPath, startLine: from + 1, wrap: 'truncate-end' });
385}
386
387function toolbar(ui: PaneElements, m: Model, actions: PaneActions): RenderElement {
388 if (m.isFindOpen) {
389 return ui.Input({
390 key: 'find',
391 label: 'find',
392 placeholder: 'file name…',
393 value: m.findQuery,
394 submitLabel: 'go',
395 autoFocus: true,
396 onInput: (value: string) => actions.onInputFind(value),
397 onSubmit: (value: string) => actions.onSubmitFind(value),
398 });
399 }
400 const key = (name: string, label: string, onPress: () => void): RenderElement =>
401 ui.Button({ key: 'tb-' + name, label, hotkey: name, plain: true, onPress });
402 return ui.Box({
403 flexDirection: 'row',
404 columnGap: 1,
405 children: [
406 key('f', 'find', actions.onToggleFind),
407 key('v', 'view', actions.onCycleView),
408 key('n', 'next', actions.onNextChanged),
409 key('p', 'prev', actions.onPrevChanged),
410 key('b', m.baseline === 'HEAD' ? 'base HEAD' : 'base Base', actions.onCycleBaseline),
411 key('c', m.changedOnly ? 'all files' : 'changed', actions.onToggleChangedOnly),
412 key('l', m.layoutSide === 'tree-left' ? 'tree|file' : 'file|tree', actions.onToggleLayout),
413 key('r', 'reload', actions.onRefresh),
414 ],
415 });
416}
417
418/** Build the pane's element tree from the model and the size of this pass. */
419export function renderPane(
420 ui: PaneElements,
421 m: Model,
422 size: PaneSize,
423 actions: PaneActions,
424): RenderElement {
425 const layout = layoutOf(size);
426 const start = clamp(m.treeStart, 0, Math.max(0, m.rows.length - layout.treeWindowRows));
427 const windowRows = m.rows.slice(start, start + layout.treeWindowRows);
428
429 // Rows split into a coloured marker cell (herdr row_color) + a pressable
430 // name Button — Button is a leaf and cannot carry colour, so the marker
431 // (selection slot + M/A/D/?/●) lives in an adjacent Text.
432 const treeChildren: RenderElement[] = windowRows.map((row) => {
433 const isSelected = row.path === m.selectedPath;
434 const markerProps: Record<string, unknown> = { children: [markerCell(row, isSelected)] };
435 const color = markerColor(row);
436 if (color !== undefined) markerProps.color = color;
437 const nameProps: Record<string, unknown> = {
438 key: 'row:' + row.path,
439 label: rowName(row),
440 plain: true,
441 dimColor: row.kind === 'dir',
442 onPress: () => actions.onRowPress(row.path, row.kind),
443 };
444 if (isSelected) nameProps.autoFocus = true;
445 return ui.Box({
446 flexDirection: 'row',
447 children: [ui.Text(markerProps), ui.Button(nameProps)],
448 });
449 });
450 if (windowRows.length === 0) {
451 treeChildren.push(ui.Text({ children: ['(empty)'], dimColor: true }));
452 }
453
454 const from = clamp(m.contentStart, 0, Math.max(0, m.contentLines.length));
455 const to = clamp(from + layout.contentRows, from, m.contentLines.length);
456 const first = m.rows.length === 0 ? 0 : start + 1;
457 const last = m.rows.length === 0 ? 0 : start + windowRows.length;
458 const treeFocus = m.focusSide === 'tree';
459 const contentFocus = m.focusSide === 'content';
460
461 return ui.Box({
462 flexDirection: 'column',
463 children: [
464 toolbar(ui, m, actions),
465 ui.Box({
466 flexDirection: m.layoutSide === 'tree-right' ? 'row-reverse' : 'row',
467 columnGap: 1,
468 children: [
469 ui.Box({
470 width: layout.treeColumns,
471 height: layout.treeRows,
472 borderStyle: 'single',
473 borderColor: treeFocus ? FOCUS_BORDER : IDLE_BORDER,
474 flexDirection: 'column',
475 children: [
476 // herdr tree border title: the root directory's basename.
477 ui.Text({ children: [m.rootName === '' ? 'Files' : m.rootName], dimColor: true, wrap: 'truncate-end' }),
478 ...treeChildren,
479 ],
480 }),
481 ui.Box({
482 flexDirection: 'column',
483 flexGrow: 1,
484 borderStyle: 'single',
485 borderColor: contentFocus ? FOCUS_BORDER : IDLE_BORDER,
486 children: [
487 ui.Text({ children: [headerText(m, from, to)], dimColor: true, wrap: 'truncate-end' }),
488 contentBody(ui, m, from, to),
489 ],
490 }),
491 ],
492 }),
493 // herdr's reversed status bar: branch · baseline · filter · notice.
494 ui.Text({ children: [statusText(m, first, last)], inverse: true, wrap: 'truncate-end' }),
495 ],
496 });
497}
498hooks/model.ts 655 lines1import type { FileRef, GitStatus, Host, NodeKind, TreeNode } from './core/types.ts';
2import { createTreeState, listRows, nextChanged, toggle, type TreeState } from './core/tree.ts';
3import { describeFile } from './core/view_policy.ts';
4import { fuzzyFilter } from './core/fuzzy.ts';
5import {
6 changedSet,
7 currentBranch,
8 defaultBaseline,
9 fileDiff,
10 status as gitStatus,
11} from './core/git.ts';
12import { createHost, type Engine } from './host.ts';
13import {
14 cycleMode,
15 layoutOf,
16 resolveMode,
17 type Model,
18 type PaneActions,
19 type PaneSize,
20} from './pane.ts';
21
22const REDRAW_MS = 120;
23const REFRESH_MS = 150;
24const CONTENT_MS = 90;
25const MAX_CONTENT_CHARS = 200000;
26const LINE_CONTEXT = 10;
27const HIDE_HIDDEN = true;
28const NUL = String.fromCharCode(0);
29const CONTROL = new RegExp('[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]', 'g');
30
31const EMPTY: Model = {
32 rows: [],
33 treeStart: 0,
34 contentStart: 0,
35 contentLines: [],
36 contentKind: 'none',
37 viewMode: 'syntaxContent',
38 baseline: 'HEAD',
39 changedView: 'diff',
40 changedOnly: false,
41 layoutSide: 'tree-left',
42 isRepo: false,
43 isFindOpen: false,
44 findQuery: '',
45 status: '',
46 rootName: '',
47 focusSide: 'toolbar',
48};
49
50let engine: Engine | null = null;
51let cwd = '';
52let repoRoot = '';
53let isRepo = false;
54let host: Host | null = null;
55let tree: TreeState = createTreeState('');
56let statusMap = new Map<string, GitStatus>();
57/** Baseline changed-set (herdr changed_filter): fallback markers + `c` source. */
58let changedMap = new Map<string, GitStatus>();
59let m: Model = { ...EMPTY };
60let shown = false;
61let size: PaneSize = { bodyColumns: 80, bodyRows: 24 };
62
63let refreshTimer: { cancel(): void } | null = null;
64let redrawTimer: { cancel(): void } | null = null;
65let contentTimer: { cancel(): void } | null = null;
66let refreshing = false;
67let refreshQueued = false;
68let contentGeneration = 0;
69let pendingLine: number | undefined;
70let prefsKey = '';
71
72export function toPosix(path: string): string {
73 return path.replace(/\\/g, '/');
74}
75
76export function normalizeRel(path: string): string {
77 const out: string[] = [];
78 for (const part of path.split('/')) {
79 if (part === '' || part === '.') continue;
80 if (part === '..') {
81 const last = out[out.length - 1];
82 if (out.length > 0 && last !== '..') out.pop();
83 else out.push('..');
84 } else {
85 out.push(part);
86 }
87 }
88 return out.join('/');
89}
90
91function isAbsolutePath(path: string): boolean {
92 return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
93}
94
95function relativeFrom(base: string, target: string): string {
96 const from = base.replace(/\/+$/, '');
97 const to = target.replace(/\/+$/, '');
98 if (to === from) return '';
99 if (from !== '' && to.startsWith(from + '/')) return to.slice(from.length + 1);
100 return '';
101}
102
103/**
104 * Resolve a `/files` argument to a repo-root-relative POSIX path.
105 * Relative arguments resolve against the session cwd; absolute ones must sit
106 * under the repo root. Null when the path is outside the repository.
107 */
108export function resolveRepoRelPath(input: string, sessionCwd: string, root: string): string | null {
109 const raw = toPosix(input.trim());
110 const from = toPosix(sessionCwd);
111 const base = toPosix(root);
112 if (raw === '' || base === '') return null;
113 if (isAbsolutePath(raw)) {
114 if (raw === base) return '';
115 if (raw.startsWith(base + '/')) return normalizeRel(raw.slice(base.length + 1));
116 return null;
117 }
118 const cwdRel = relativeFrom(base, from);
119 return normalizeRel(cwdRel === '' ? raw : `${cwdRel}/${raw}`);
120}
121
122function errorText(err: unknown): string {
123 if (err instanceof Error) return err.message;
124 return String(err);
125}
126
127function sanitize(text: string): string {
128 return text.replace(/\r\n?/g, '\n').replace(CONTROL, '');
129}
130
131function storeKey(name: string): string {
132 return `file-viewer:${repoRoot}:${name}`;
133}
134
135export function bindEngine(next: Engine, sessionCwd: string): void {
136 engine = next;
137 if (sessionCwd !== '') cwd = toPosix(sessionCwd);
138}
139
140export function resetSession(sessionCwd: string): void {
141 engine = null;
142 cwd = sessionCwd === '' ? '' : toPosix(sessionCwd);
143 repoRoot = '';
144 isRepo = false;
145 host = null;
146 tree = createTreeState('');
147 statusMap = new Map();
148 changedMap = new Map();
149 m = { ...EMPTY };
150 shown = false;
151 size = { bodyColumns: 80, bodyRows: 24 };
152 refreshTimer?.cancel();
153 refreshTimer = null;
154 redrawTimer?.cancel();
155 redrawTimer = null;
156 contentTimer?.cancel();
157 contentTimer = null;
158 refreshing = false;
159 refreshQueued = false;
160 contentGeneration += 1;
161 pendingLine = undefined;
162 prefsKey = '';
163}
164
165export function model(): Model {
166 return m;
167}
168
169export function setShown(value: boolean): void {
170 shown = value;
171}
172
173function redraw(): void {
174 if (!engine || redrawTimer !== null) return;
175 redrawTimer = engine.after(REDRAW_MS, () => {
176 redrawTimer = null;
177 engine?.invalidate();
178 });
179}
180
181export function scheduleRefresh(): void {
182 if (!engine) return;
183 refreshTimer?.cancel();
184 refreshTimer = engine.after(REFRESH_MS, () => {
185 refreshTimer = null;
186 void refresh().catch(() => undefined);
187 });
188}
189
190function ensureVisible(): void {
191 const layout = layoutOf(size);
192 const at = m.rows.findIndex((row) => row.path === m.selectedPath);
193 let start = m.treeStart;
194 if (at >= 0) {
195 if (at < start) start = at;
196 else if (at >= start + layout.treeWindowRows) start = at - layout.treeWindowRows + 1;
197 }
198 const maxStart = Math.max(0, m.rows.length - layout.treeWindowRows);
199 start = start < 0 ? 0 : start > maxStart ? maxStart : start;
200 if (start !== m.treeStart) m = { ...m, treeStart: start };
201}
202
203export function noteSize(next: PaneSize): void {
204 size = next;
205 ensureVisible();
206}
207
208async function ensureRepo(): Promise<void> {
209 if (repoRoot !== '' || !engine) return;
210 repoRoot = cwd === '' ? '/' : cwd;
211 const probe = createHost(engine, repoRoot);
212 try {
213 const answer = await probe.runGit(cwd, ['rev-parse', '--show-toplevel']);
214 const top = answer.stdout.trim();
215 if (answer.exitCode === 0 && top !== '') {
216 repoRoot = toPosix(top);
217 isRepo = true;
218 }
219 } catch {
220 isRepo = false;
221 }
222 host = createHost(engine, repoRoot);
223 tree = createTreeState('');
224 m = { ...m, rootName: baseNameOf(repoRoot === cwd ? cwd : repoRoot) };
225}
226
227function baseNameOf(path: string): string {
228 const clean = path.replace(/\/+$/, '');
229 const at = clean.lastIndexOf('/');
230 return at === -1 ? clean : clean.slice(at + 1);
231}
232
233async function loadPrefs(): Promise<void> {
234 if (!engine || prefsKey === repoRoot) return;
235 prefsKey = repoRoot;
236 try {
237 const baseline = await engine.storeGet(storeKey('baseline'));
238 if (baseline === 'HEAD' || baseline === 'Base') {
239 m = { ...m, baseline };
240 } else if (host && isRepo) {
241 // herdr default_baseline: no stored preference → context-smart (AC-14/15).
242 m = { ...m, baseline: await defaultBaseline(host, repoRoot) };
243 }
244 const changedOnly = await engine.storeGet(storeKey('changedOnly'));
245 if (typeof changedOnly === 'boolean') m = { ...m, changedOnly };
246 const changedView = await engine.storeGet(storeKey('changedView'));
247 if (changedView === 'diff' || changedView === 'content') m = { ...m, changedView };
248 const layout = await engine.storeGet(storeKey('layout'));
249 if (layout === 'tree-left' || layout === 'tree-right') m = { ...m, layoutSide: layout };
250 } catch {
251 m = { ...m, status: 'preferences unavailable' };
252 }
253}
254
255async function savePref(name: string, value: unknown): Promise<void> {
256 try {
257 await engine?.storeSet(storeKey(name), value);
258 } catch {
259 m = { ...m, status: 'preference not saved' };
260 }
261}
262
263/** Marker/policy status for a path: working-tree status wins, baseline set fills in. */
264function statusOf(path: string): GitStatus | undefined {
265 return statusMap.get(path) ?? changedMap.get(path);
266}
267
268/** Track which column was touched last (herdr focus → border highlight). */
269export function setFocusSide(side: Model['focusSide']): void {
270 if (m.focusSide === side) return;
271 m = { ...m, focusSide: side };
272 redraw();
273}
274
275async function rebuildRows(): Promise<void> {
276 if (!host) return;
277 try {
278 const rows = await listRows(host, tree, statusMap, {
279 changedOnly: m.changedOnly,
280 hideHidden: HIDE_HIDDEN,
281 changedSet: changedMap,
282 });
283 m = { ...m, rows };
284 } catch (err) {
285 m = { ...m, rows: [], status: 'tree unavailable: ' + errorText(err) };
286 }
287}
288
289export async function refresh(): Promise<void> {
290 if (refreshing) {
291 refreshQueued = true;
292 return;
293 }
294 refreshing = true;
295 try {
296 await ensureRepo();
297 if (!host) return;
298 let next = new Map<string, GitStatus>();
299 try {
300 next = await gitStatus(host, repoRoot);
301 } catch {
302 next = new Map();
303 m = { ...m, status: 'git unavailable' };
304 }
305 statusMap = next;
306 await loadPrefs();
307 // Baseline changed-set (herdr refresh_git_state): marker fallback + `c` source.
308 try {
309 changedMap = isRepo ? await changedSet(host, repoRoot, m.baseline) : new Map();
310 } catch {
311 changedMap = new Map();
312 }
313 let branch: string | undefined;
314 try {
315 branch = isRepo ? await currentBranch(host, repoRoot) : undefined;
316 } catch {
317 branch = undefined;
318 }
319 await rebuildRows();
320 ensureVisible();
321 m = { ...m, isRepo, branch };
322 if (shown && m.selectedKind === 'file' && m.selectedPath !== undefined) scheduleContent();
323 } catch (err) {
324 m = { ...m, rows: [], status: 'refresh failed: ' + errorText(err) };
325 } finally {
326 refreshing = false;
327 redraw();
328 if (refreshQueued) {
329 refreshQueued = false;
330 scheduleRefresh();
331 }
332 }
333}
334
335function scheduleContent(): void {
336 if (!engine) return;
337 contentTimer?.cancel();
338 const path = m.selectedPath;
339 const line = pendingLine;
340 pendingLine = undefined;
341 contentTimer = engine.after(CONTENT_MS, () => {
342 contentTimer = null;
343 void loadContent(path, line).catch(() => undefined);
344 });
345}
346
347async function loadContent(path: string | undefined, line: number | undefined): Promise<void> {
348 if (!host || path === undefined || m.selectedPath !== path) return;
349 const generation = ++contentGeneration;
350 try {
351 if (m.viewMode === 'diff' || m.viewMode === 'fullDiff') {
352 const text = await fileDiff(host, repoRoot, path, m.baseline, {
353 fullContext: m.viewMode === 'fullDiff',
354 });
355 if (generation !== contentGeneration) return;
356 if (text.trim() === '') {
357 m = {
358 ...m,
359 contentKind: 'none',
360 contentLines: [],
361 contentStart: 0,
362 notice: `no changes vs ${m.baseline}`,
363 };
364 redraw();
365 return;
366 }
367 const lines = sanitize(text).split('\n');
368 const start =
369 line === undefined ? 0 : Math.max(0, Math.min(line - 1 - LINE_CONTEXT, lines.length - 1));
370 m = { ...m, contentKind: 'diff', contentLines: lines, contentStart: start, notice: undefined };
371 redraw();
372 return;
373 }
374 const text = await host.readText(path);
375 if (generation !== contentGeneration) return;
376 if (text.includes(NUL)) {
377 m = { ...m, contentKind: 'none', contentLines: [], contentStart: 0, notice: 'binary file' };
378 redraw();
379 return;
380 }
381 const clipped = sanitize(text).slice(0, MAX_CONTENT_CHARS);
382 const lines = clipped.split('\n');
383 const start =
384 line === undefined ? 0 : Math.max(0, Math.min(line - 1 - LINE_CONTEXT, lines.length - 1));
385 m = { ...m, contentKind: 'source', contentLines: lines, contentStart: start, notice: undefined };
386 } catch (err) {
387 if (generation !== contentGeneration) return;
388 m = {
389 ...m,
390 contentKind: 'none',
391 contentLines: [],
392 contentStart: 0,
393 notice: `cannot read ${path}: ${errorText(err)}`,
394 status: 'read failed',
395 };
396 }
397 redraw();
398}
399
400export function selectPath(path: string, kind: NodeKind, line?: number): void {
401 const changed = m.selectedPath !== path;
402 tree = { ...tree, selected: { path, kind } };
403 m = { ...m, selectedPath: path, selectedKind: kind };
404 if (kind !== 'file') {
405 m = {
406 ...m,
407 contentKind: 'none',
408 contentLines: [],
409 contentStart: 0,
410 contentPath: undefined,
411 notice: 'directory',
412 };
413 ensureVisible();
414 redraw();
415 return;
416 }
417 if (changed || line !== undefined) {
418 const fd = describeFile(path, statusOf(path));
419 const mode =
420 line !== undefined && !fd.isDeleted
421 ? fd.isMarkdown
422 ? ('renderedMarkdown' as const)
423 : ('syntaxContent' as const)
424 : resolveMode(fd, m.changedView, undefined);
425 m = {
426 ...m,
427 viewMode: mode,
428 contentKind: 'none',
429 contentLines: [],
430 contentStart: 0,
431 contentPath: path,
432 notice: undefined,
433 };
434 }
435 pendingLine = line;
436 scheduleContent();
437 ensureVisible();
438 redraw();
439}
440
441function moveSelection(delta: number): void {
442 if (m.rows.length === 0) return;
443 const found = m.rows.findIndex((row) => row.path === m.selectedPath);
444 const at = found < 0 ? 0 : found;
445 const target = at + delta;
446 const index = target < 0 ? 0 : target >= m.rows.length ? m.rows.length - 1 : target;
447 const row = m.rows[index] as TreeNode;
448 selectPath(row.path, row.kind);
449}
450
451export function focusRow(path: string): void {
452 const at = m.rows.findIndex((row) => row.path === path);
453 if (at < 0) return;
454 const layout = layoutOf(size);
455 const bottom = m.treeStart + layout.treeWindowRows - 1;
456 if (at === bottom && at < m.rows.length - 1) m = { ...m, treeStart: m.treeStart + 1 };
457 else if (at === m.treeStart && m.treeStart > 0) m = { ...m, treeStart: m.treeStart - 1 };
458 const row = m.rows[at] as TreeNode;
459 if (m.selectedPath !== path) selectPath(row.path, row.kind);
460}
461
462function scrollContent(by: number, contentRows: number): void {
463 const maxStart = Math.max(0, m.contentLines.length - contentRows);
464 const start = m.contentStart + by;
465 const next = start < 0 ? 0 : start > maxStart ? maxStart : start;
466 if (next !== m.contentStart) m = { ...m, contentStart: next };
467 redraw();
468}
469
470export function handleScroll(by: number, bodyRows: number, pointerColumn?: number): void {
471 const layout = layoutOf({ bodyColumns: size.bodyColumns, bodyRows });
472 const overTree =
473 pointerColumn !== undefined &&
474 (m.layoutSide === 'tree-right'
475 ? pointerColumn >= size.bodyColumns - layout.treeColumns
476 : pointerColumn < layout.treeColumns);
477 if (pointerColumn !== undefined) setFocusSide(overTree ? 'tree' : 'content');
478 if (overTree) moveSelection(by);
479 else scrollContent(by, layout.contentRows);
480}
481
482/** Load the selected file's content when a drawing asks for text that isn't there yet. */
483export function ensureContent(): void {
484 if (contentTimer !== null) return;
485 if (m.selectedKind !== 'file' || m.selectedPath === undefined) return;
486 if (m.contentKind !== 'none' || m.notice !== undefined || m.contentLines.length > 0) return;
487 scheduleContent();
488}
489
490async function toggleDir(path: string): Promise<void> {
491 tree = toggle(tree, path);
492 tree = { ...tree, selected: { path, kind: 'dir' } };
493 m = {
494 ...m,
495 selectedPath: path,
496 selectedKind: 'dir',
497 contentKind: 'none',
498 contentLines: [],
499 contentStart: 0,
500 contentPath: undefined,
501 notice: 'directory',
502 };
503 await rebuildRows();
504 ensureVisible();
505 redraw();
506}
507
508async function reveal(path: string): Promise<void> {
509 const parts = path.split('/');
510 const expanded = new Set(tree.expanded);
511 let acc = '';
512 for (let i = 0; i < parts.length - 1; i += 1) {
513 acc = acc === '' ? (parts[i] as string) : `${acc}/${parts[i] as string}`;
514 expanded.add(acc);
515 }
516 tree = { ...tree, expanded };
517 if (m.changedOnly && !statusOf(path)) {
518 m = { ...m, changedOnly: false };
519 await savePref('changedOnly', false);
520 }
521 await rebuildRows();
522}
523
524async function submitFind(value: string): Promise<void> {
525 const query = value.trim();
526 m = { ...m, isFindOpen: false, findQuery: '' };
527 if (query === '') {
528 redraw();
529 return;
530 }
531 const files = m.rows.filter((row) => row.kind === 'file');
532 const hits = fuzzyFilter(query, files, (row) => row.path);
533 const target = hits.length === 0 ? '' : (hits[0]?.item.path ?? '');
534 if (target === '') {
535 m = { ...m, status: `no match: ${query}` };
536 redraw();
537 return;
538 }
539 await reveal(target);
540 const row = m.rows.find((candidate) => candidate.path === target);
541 if (row) selectPath(row.path, row.kind);
542 else m = { ...m, status: `not found: ${target}` };
543 redraw();
544}
545
546async function cycleBaseline(): Promise<void> {
547 m = { ...m, baseline: m.baseline === 'HEAD' ? 'Base' : 'HEAD' };
548 await savePref('baseline', m.baseline);
549 // herdr `b`: markers and the changed-only filter are baseline-dependent too.
550 if (host && isRepo) {
551 try {
552 changedMap = await changedSet(host, repoRoot, m.baseline);
553 } catch {
554 changedMap = new Map();
555 }
556 await rebuildRows();
557 ensureVisible();
558 }
559 if (m.selectedKind === 'file') {
560 pendingLine = undefined;
561 scheduleContent();
562 }
563 redraw();
564}
565
566async function toggleChangedOnly(): Promise<void> {
567 m = { ...m, changedOnly: !m.changedOnly };
568 await savePref('changedOnly', m.changedOnly);
569 await rebuildRows();
570 ensureVisible();
571 redraw();
572}
573
574async function toggleLayout(): Promise<void> {
575 m = { ...m, layoutSide: m.layoutSide === 'tree-left' ? 'tree-right' : 'tree-left' };
576 await savePref('layout', m.layoutSide);
577 redraw();
578}
579
580function cycleView(): void {
581 const path = m.selectedPath;
582 if (path === undefined || m.selectedKind !== 'file') return;
583 const fd = describeFile(path, statusOf(path));
584 m = { ...m, viewMode: cycleMode(fd, m.changedView, m.viewMode) };
585 pendingLine = undefined;
586 scheduleContent();
587 redraw();
588}
589
590function jumpChanged(direction: 1 | -1): void {
591 const target = nextChanged(m.rows as TreeNode[], m.selectedPath, direction);
592 if (target === undefined) {
593 m = { ...m, status: 'no changed files' };
594 redraw();
595 return;
596 }
597 const row = m.rows.find((candidate) => candidate.path === target);
598 if (row) selectPath(row.path, row.kind);
599}
600
601/** Select what `/files path[:line]` asked for, opening collapsed folders on the way. */
602export async function openTarget(ref: FileRef): Promise<void> {
603 const rel = resolveRepoRelPath(ref.path, cwd, repoRoot);
604 if (rel === null || rel === '') {
605 m = { ...m, status: `outside repository: ${ref.path}` };
606 redraw();
607 return;
608 }
609 if (HIDE_HIDDEN && rel.split('/').some((part) => part.startsWith('.'))) {
610 m = { ...m, status: `hidden path: ${rel}` };
611 redraw();
612 return;
613 }
614 await reveal(rel);
615 const row = m.rows.find((candidate) => candidate.path === rel);
616 if (!row) {
617 m = { ...m, status: `not found: ${rel}` };
618 redraw();
619 return;
620 }
621 selectPath(row.path, row.kind, ref.line);
622}
623
624export const actions: PaneActions = {
625 onRowPress: (path, kind) => {
626 if (kind === 'dir') void toggleDir(path).catch(() => undefined);
627 else selectPath(path, 'file');
628 },
629 onToggleFind: () => {
630 m = { ...m, isFindOpen: !m.isFindOpen, findQuery: '' };
631 redraw();
632 },
633 onInputFind: (value) => {
634 m = { ...m, findQuery: value };
635 },
636 onSubmitFind: (value) => {
637 void submitFind(value).catch(() => undefined);
638 },
639 onCycleView: cycleView,
640 onNextChanged: () => jumpChanged(1),
641 onPrevChanged: () => jumpChanged(-1),
642 onCycleBaseline: () => {
643 void cycleBaseline().catch(() => undefined);
644 },
645 onToggleChangedOnly: () => {
646 void toggleChangedOnly().catch(() => undefined);
647 },
648 onToggleLayout: () => {
649 void toggleLayout().catch(() => undefined);
650 },
651 onRefresh: () => {
652 void refresh().catch(() => undefined);
653 },
654};
655hooks/core/git.ts 315 lines1/**
2 * git service — read-only answers (port of herdr-file-viewer src/git.rs).
3 *
4 * Hardening contract (from the Rust original):
5 * - Only read-only git subcommands; no shell; argv arrays only.
6 * - Neutralize repo-controlled code execution: --no-ext-diff, --no-textconv (diff
7 * subcommand level — see SAFE_DIFF_FLAGS), -c core.fsmonitor=,
8 * -c core.hooksPath=/dev/null (NUL on Windows) as global flags.
9 * - GIT_OPTIONAL_LOCKS=0 so status/diff never write the index.
10 * Adapters implement this via Host.runGit; this module only composes args and
11 * parses output. See SAFE_GIT_FLAGS / SAFE_DIFF_FLAGS / SAFE_GIT_ENV.
12 */
13import type { Baseline, GitStatus, Host, RunResult } from './types.ts';
14
15/**
16 * Flags every git call must carry (adapters prepend these to argv). Only options
17 * git accepts BEFORE the subcommand live here: global flags and `-c` overrides.
18 * (`--no-ext-diff` / `--no-textconv` are `git diff` options — git rejects them as
19 * global options with exit 129 — so they are NOT part of this list.)
20 */
21export const SAFE_GIT_FLAGS = (isWindows: boolean): string[] => [
22 '-c', 'core.fsmonitor=',
23 '-c', 'core.hooksPath=' + (isWindows ? 'NUL' : '/dev/null'),
24];
25
26/**
27 * Diff-level safety flags (repo-controlled ext-diff/textconv drivers off). They are
28 * `git diff` OPTIONS, so they must be inserted AFTER the `diff` subcommand — which
29 * is where this module puts them when composing diff argv.
30 */
31export const SAFE_DIFF_FLAGS: string[] = ['--no-ext-diff', '--no-textconv'];
32
33/** The env every git call must carry. */
34export const SAFE_GIT_ENV: Record<string, string> = { GIT_OPTIONAL_LOCKS: '0' };
35
36/** git's empty-tree object — baseline for an unborn repo. */
37export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
38
39/** Full-context diff: -U larger than any real file. */
40export const FULL_CONTEXT = '-U1000000';
41
42/** Hard cap on diff bytes the host should capture (4 MB). */
43export const MAX_DIFF_BYTES = 4 * 1024 * 1024;
44
45/** The NUL byte git uses as its -z field delimiter. */
46const NUL = String.fromCharCode(0);
47
48/**
49 * Parse `git status --porcelain=v1 -z -uall` output into a per-path status map.
50 * Key: repo-root-relative path, POSIX separators (backslashes normalized).
51 * Pure: string in, map out. Defensive: truncated records and blank codes are
52 * skipped; a rename/copy consumes the extra NUL field holding the ORIGINAL path
53 * (in `-z` the record's own path is the NEW one) and keys that new path.
54 */
55export function parsePorcelainStatus(out: string): Map<string, GitStatus> {
56 const map = new Map<string, GitStatus>();
57 const fields = out.split(NUL).filter((f) => f.length > 0);
58 for (let i = 0; i < fields.length; i++) {
59 const rec = fields[i];
60 if (rec.length < 3) continue;
61 const code = rec.slice(0, 2);
62 const rawPath = rec.slice(3);
63 // Rename/copy: next NUL field is the original path — consume it so it is not
64 // parsed as a record of its own; the record's own (new) path is still keyed.
65 if (code.includes('R') || code.includes('C')) i++;
66 const status = classify(code);
67 if (!status) continue;
68 const path = rawPath.replace(/\\/g, '/');
69 if (!map.has(path)) map.set(path, status);
70 }
71 return map;
72}
73
74/**
75 * Two-letter XY code → GitStatus, or undefined when the code is blank
76 * (unmodified/ignored). Unknown-but-non-empty codes (unmerged `UU`, copy `C `,
77 * anything git may grow) fall through to Modified, matching git.rs.
78 */
79export function classify(code: string): GitStatus | undefined {
80 if (code === '??') return 'Untracked';
81 if (code.includes('D')) return 'Deleted';
82 if (code.includes('A')) return 'Added';
83 if (code.trim() === '') return undefined;
84 return 'Modified';
85}
86
87/** Per-file working-tree status for tree markers. Not a repo / git missing → empty. */
88export async function status(host: Host, repoRootPath: string): Promise<Map<string, GitStatus>> {
89 const r = await host.runGit(repoRootPath, ['status', '--porcelain=v1', '-z', '-uall']);
90 if (r.exitCode !== 0) return new Map();
91 return parsePorcelainStatus(r.stdout);
92}
93
94/** True when dir is inside a git work tree. */
95export async function isRepo(host: Host, dir: string): Promise<boolean> {
96 const r = await host.runGit(dir, ['rev-parse', '--is-inside-work-tree']);
97 return r.exitCode === 0 && r.stdout.trim() === 'true';
98}
99
100/** Repo root (absolute, POSIX-normalized) for dir, or undefined when not a repo. */
101export async function repoRoot(host: Host, dir: string): Promise<string | undefined> {
102 const r = await host.runGit(dir, ['rev-parse', '--show-toplevel']);
103 if (r.exitCode !== 0) return undefined;
104 const root = r.stdout.trim();
105 return root.length > 0 ? root.replace(/\\/g, '/') : undefined;
106}
107
108/**
109 * Unified diff text for one repo-root-relative path against the baseline.
110 * - Untracked: `git diff --no-index` against the null device (whole file as added).
111 * - baseline 'HEAD': `git diff HEAD -- path` (unborn HEAD → empty-tree retry).
112 * - baseline 'Base': merge-base against opts.baseBranch (or the default base branch).
113 * Not a repo / git failure → '' (degrade to neutral, AC-26).
114 */
115export async function fileDiff(
116 host: Host,
117 repoRootPath: string,
118 relPath: string,
119 baseline: Baseline,
120 opts: { baseBranch?: string; fullContext?: boolean } = {},
121): Promise<string> {
122 const norm = relPath.replace(/\\/g, '/');
123
124 if (baseline === 'HEAD') {
125 const st = await status(host, repoRootPath);
126 if (st.get(norm) === 'Untracked') return untrackedDiff(host, repoRootPath, norm);
127 const args = ['diff', ...SAFE_DIFF_FLAGS];
128 if (opts.fullContext) args.push(FULL_CONTEXT);
129 args.push('HEAD', '--', norm);
130 const r = await host.runGit(repoRootPath, args);
131 if (r.exitCode === 0) return r.stdout;
132 // Unborn HEAD: `git diff HEAD` fails with "bad revision 'HEAD'" — retry against
133 // the empty tree so a fresh repo's first (staged) files still diff as additions
134 // (git.rs head_or_empty_tree). A non-repo fails again and degrades to ''.
135 const retry = [...args];
136 retry[retry.indexOf('HEAD')] = EMPTY_TREE;
137 const r2 = await host.runGit(repoRootPath, retry);
138 return r2.exitCode === 0 ? r2.stdout : '';
139 }
140
141 const base = opts.baseBranch ?? (await defaultBaseBranch(host, repoRootPath));
142 if (!base) {
143 // No base branch: fall back to HEAD behaviour.
144 return fileDiff(host, repoRootPath, norm, 'HEAD', opts);
145 }
146 const mb = await host.runGit(repoRootPath, ['merge-base', 'HEAD', base]);
147 const from = mb.exitCode === 0 && mb.stdout.trim() ? mb.stdout.trim() : base;
148 const st = await status(host, repoRootPath);
149 if (st.get(norm) === 'Untracked') return untrackedDiff(host, repoRootPath, norm);
150 const args = ['diff', ...SAFE_DIFF_FLAGS];
151 if (opts.fullContext) args.push(FULL_CONTEXT);
152 args.push(from, '--', norm);
153 const r = await host.runGit(repoRootPath, args);
154 return r.exitCode === 0 ? r.stdout : '';
155}
156
157/** Whole-file added diff for an untracked path. */
158async function untrackedDiff(host: Host, repoRootPath: string, norm: string): Promise<string> {
159 const nullDev = host.isWindows ? 'NUL' : '/dev/null';
160 const r = await host.runGit(repoRootPath, [
161 'diff',
162 ...SAFE_DIFF_FLAGS,
163 '--no-index',
164 '--',
165 nullDev,
166 norm,
167 ]);
168 // git diff --no-index exits 1 when the files differ — still a valid diff.
169 if (r.exitCode !== 0 && r.exitCode !== 1) return '';
170 return normalizeNoIndex(r.stdout, norm);
171}
172
173/**
174 * `git diff --no-index /dev/null path` headers reference the null device —
175 * rewrite them so the diff reads as a plain add of `relPath`.
176 */
177export function normalizeNoIndex(stdout: string, relPath: string): string {
178 return stdout
179 .split('\n')
180 .map((line) => {
181 if (line.startsWith('diff --git')) return `diff --git a/${relPath} b/${relPath}`;
182 if (line.startsWith('--- ')) return '--- /dev/null';
183 if (line.startsWith('+++ ')) return `+++ b/${relPath}`;
184 return line;
185 })
186 .join('\n');
187}
188
189/** Best-effort default base branch: origin/HEAD → origin/main → origin/master → main → master. */
190export async function defaultBaseBranch(host: Host, repoRootPath: string): Promise<string | undefined> {
191 const sym = await host.runGit(repoRootPath, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
192 if (sym.exitCode === 0) {
193 const m = /refs\/remotes\/(.+)$/.exec(sym.stdout.trim());
194 if (m) return m[1];
195 }
196 for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
197 const r = await host.runGit(repoRootPath, ['rev-parse', '--verify', cand]);
198 if (r.exitCode === 0) return cand;
199 }
200 return undefined;
201}
202
203/** The current branch, or undefined outside a repo / on a detached HEAD (git.rs current_branch). */
204export async function currentBranch(host: Host, repoRootPath: string): Promise<string | undefined> {
205 const r = await host.runGit(repoRootPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
206 if (r.exitCode !== 0) return undefined;
207 const out = r.stdout.trim();
208 return out !== '' && out !== 'HEAD' ? out : undefined;
209}
210
211/**
212 * Map one `git diff --name-status` code to a tree status (git.rs
213 * classify_name_status): first letter wins — A → Added, D → Deleted,
214 * M/T/R/C → Modified; anything else (unmerged `U`, blank) → undefined.
215 */
216export function classifyNameStatus(code: string): GitStatus | undefined {
217 switch (code.charAt(0)) {
218 case 'A':
219 return 'Added';
220 case 'D':
221 return 'Deleted';
222 case 'M':
223 case 'T':
224 case 'R':
225 case 'C':
226 return 'Modified';
227 default:
228 return undefined;
229 }
230}
231
232/**
233 * Parse `git diff --name-status -z` output into a per-path status map.
234 * Fields alternate `code, path`; a rename/copy is the triple `code, old, new`
235 * and keys the NEW path (verified against git on this machine). Pure.
236 */
237export function parseNameStatus(out: string): Map<string, GitStatus> {
238 const map = new Map<string, GitStatus>();
239 const fields = out.split(NUL).filter((f) => f.length > 0);
240 for (let i = 0; i < fields.length; i++) {
241 const code = fields[i] as string;
242 const isRename = code.charAt(0) === 'R' || code.charAt(0) === 'C';
243 // Rename/copy: skip `old`, key `new`; otherwise the next field is the path.
244 const path = isRename ? fields[i + 2] : fields[i + 1];
245 i += isRename ? 2 : 1;
246 if (path === undefined) break;
247 const status = classifyNameStatus(code);
248 if (!status) continue;
249 const key = path.replace(/\\/g, '/');
250 if (!map.has(key)) map.set(key, status);
251 }
252 return map;
253}
254
255/**
256 * The baseline-aware changed-set driving herdr's tree markers and the `c`
257 * filter (git.rs changed_set): `HEAD` is exactly the working-tree status;
258 * `Base` is `git diff --name-status <fork-point>` (committed-on-branch AND
259 * uncommitted tracked changes vs the merge-base) merged with Untracked files.
260 * No resolvable base branch degrades to `HEAD` — same as fileDiff's Base path.
261 */
262export async function changedSet(
263 host: Host,
264 repoRootPath: string,
265 baseline: Baseline,
266 opts: { baseBranch?: string } = {},
267): Promise<Map<string, GitStatus>> {
268 if (baseline === 'HEAD') return status(host, repoRootPath);
269 const base = opts.baseBranch ?? (await defaultBaseBranch(host, repoRootPath));
270 if (!base) return status(host, repoRootPath);
271 const mb = await host.runGit(repoRootPath, ['merge-base', 'HEAD', base]);
272 const fork = mb.exitCode === 0 && mb.stdout.trim() !== '' ? mb.stdout.trim() : base;
273 const map = new Map<string, GitStatus>();
274 const r = await host.runGit(repoRootPath, [
275 'diff',
276 ...SAFE_DIFF_FLAGS,
277 '--name-status',
278 '-z',
279 fork,
280 ]);
281 if (r.exitCode === 0) {
282 for (const [k, v] of parseNameStatus(r.stdout)) map.set(k, v);
283 }
284 // Untracked files are not in `git diff` but belong to the body of work.
285 for (const [k, v] of await status(host, repoRootPath)) {
286 if (v === 'Untracked' && !map.has(k)) map.set(k, v);
287 }
288 return map;
289}
290
291/**
292 * Context-smart default baseline (git.rs default_baseline, AC-14/15): Base on
293 * a branch other than the base branch, else HEAD. `origin/x` and `x` compare
294 * equal (the base candidates favour remote-tracking refs while HEAD is local).
295 * Detached-HEAD worktree detection is not ported: core Host has no worktree
296 * notion, so detached → HEAD here.
297 */
298export async function defaultBaseline(host: Host, repoRootPath: string): Promise<Baseline> {
299 const base = await defaultBaseBranch(host, repoRootPath);
300 if (!base) return 'HEAD';
301 const cur = await currentBranch(host, repoRootPath);
302 if (cur === undefined) return 'HEAD';
303 const norm = (b: string): string => b.replace(/^origin\//, '');
304 return norm(base) === norm(cur) ? 'HEAD' : 'Base';
305}
306
307/** Apply SAFE flags/env around a raw runner — exported so adapters share one path. */
308export async function safeRun(
309 raw: (args: string[], env: Record<string, string>) => Promise<RunResult>,
310 args: string[],
311 isWindows = false,
312): Promise<RunResult> {
313 return raw([...SAFE_GIT_FLAGS(isWindows), ...args], SAFE_GIT_ENV);
314}
315hooks/core/view_policy.ts 56 lines1/**
2 * View policy — which content-pane view mode a file gets.
3 * Port of herdr-file-viewer src/view_policy.rs (semantics + test coverage: ★B).
4 */
5import type { ChangedFileView, FileDescriptor, ViewMode } from './types.ts';
6
7/** True when the path names a markdown file: extension `md`/`markdown`, ASCII case-insensitive
8 * (Rust reference: controller::is_markdown — mdx/mdown/mkd are *not* markdown there either). */
9export function isMarkdownPath(path: string): boolean {
10 const name = path.slice(path.lastIndexOf('/') + 1);
11 const dot = name.lastIndexOf('.');
12 if (dot <= 0) return false; // no extension, or a dotfile (".gitignore" → Rust Path::extension → None)
13 const ext = name.slice(dot + 1).toLowerCase();
14 return ext === 'md' || ext === 'markdown';
15}
16
17/** Build the facts the policy needs from a path + cached status. */
18export function describeFile(
19 path: string,
20 status: 'Modified' | 'Added' | 'Deleted' | 'Untracked' | undefined,
21): FileDescriptor {
22 return {
23 path,
24 isMarkdown: isMarkdownPath(path),
25 isChanged: status !== undefined,
26 isDeleted: status === 'Deleted',
27 };
28}
29
30/** The normal non-Git view mode for a file, based only on its type. */
31function contentMode(fd: FileDescriptor): ViewMode {
32 return fd.isMarkdown ? 'renderedMarkdown' : 'syntaxContent';
33}
34
35/** The auto-selected default view mode for a file. */
36export function defaultMode(fd: FileDescriptor, pref: ChangedFileView): ViewMode {
37 if (fd.isChanged && (fd.isDeleted || pref === 'diff')) return 'diff';
38 return contentMode(fd);
39}
40
41/** The modes a cycle key steps through for a file, default first (AC-11). */
42export function applicableModes(fd: FileDescriptor, pref: ChangedFileView): ViewMode[] {
43 const modes: ViewMode[] = [defaultMode(fd, pref)];
44 const add = (m: ViewMode) => { if (!modes.includes(m)) modes.push(m); };
45 if (fd.isChanged) { add('diff'); add('fullDiff'); }
46 if (fd.isMarkdown) add('renderedMarkdown');
47 add('syntaxContent');
48 return modes;
49}
50
51/** Advance within the cycle; wraps. */
52export function nextMode(modes: ViewMode[], current: ViewMode): ViewMode {
53 const i = modes.indexOf(current);
54 return modes[(i + 1) % modes.length];
55}
56hooks/core/tree.ts 228 lines1/**
2 * Tree model — visible rows over an expanded set, with git markers.
3 * Port of herdr-file-viewer src/tree.rs (v0.1): full walk + changed-only synthesis.
4 * Not ported (v0.2): compact_dirs folding, mouse, show_ignored.
5 *
6 * Frozen signatures (ARCHITECTURE.md §3): createTreeState / toggle / select /
7 * listRows / nextChanged / TreeState / TreeOptions / DEFAULT_TREE_OPTIONS.
8 */
9import type { DirEntry, GitStatus, Host, NodeKind, TreeNode } from './types.ts';
10
11export interface TreeOptions {
12 /** Synthesize a tree containing only changed paths (AC-6). */
13 changedOnly: boolean;
14 /** Hide dotfiles/dotdirs (except '.'-root basics). */
15 hideHidden: boolean;
16 /**
17 * Baseline changed-set (git.rs changed_filter): fallback markers so files
18 * changed against the baseline carry a status even when the working tree is
19 * clean, and the changed-only filter's source. Working-tree status always
20 * wins over this map (git.rs status_for precedence).
21 */
22 changedSet?: Map<string, GitStatus>;
23}
24
25export const DEFAULT_TREE_OPTIONS: TreeOptions = { changedOnly: false, hideHidden: true };
26
27export interface TreeState {
28 /** Repo-root-relative root, POSIX ('' = repo root). */
29 root: string;
30 expanded: Set<string>;
31 selected?: { path: string; kind: NodeKind };
32}
33
34export function createTreeState(root = ''): TreeState {
35 return { root, expanded: new Set(['']) };
36}
37
38export function toggle(state: TreeState, path: string): TreeState {
39 const expanded = new Set(state.expanded);
40 if (expanded.has(path)) expanded.delete(path);
41 else expanded.add(path);
42 return { ...state, expanded };
43}
44
45export function select(path: string, kind: NodeKind): (s: TreeState) => TreeState {
46 return (s) => ({ ...s, selected: { path, kind } });
47}
48
49/**
50 * Produce the visible rows for the current expansion state (tree.rs `visible_nodes`).
51 * Directories before files, alphabetical within each group (cmp_sibling); status
52 * markers come from `statusMap` (repo-root-relative POSIX keys) falling back to
53 * `opts.changedSet` (git.rs `status_for`: working-tree status wins, the baseline
54 * set fills in clean-but-committed files), `dirDirty` is a component-wise prefix
55 * inference over the union of both maps, so collapsed dirs get it too.
56 * changed-only filters on `opts.changedSet` when given (herdr's `c` semantics),
57 * else on `statusMap`. Empty sources (no git repo) + changedOnly → empty rows.
58 */
59export async function listRows(
60 host: Host,
61 state: TreeState,
62 statusMap: Map<string, GitStatus>,
63 opts: TreeOptions = DEFAULT_TREE_OPTIONS,
64): Promise<TreeNode[]> {
65 const filter = opts.changedSet ?? statusMap;
66 if (opts.changedOnly) return changedOnlyRows(state, filter);
67
68 const statusOf = (path: string): GitStatus | undefined =>
69 statusMap.get(path) ?? opts.changedSet?.get(path);
70
71 const rows: TreeNode[] = [];
72 await walk(state.root, 0);
73 return rows;
74
75 async function walk(dir: string, depth: number): Promise<void> {
76 let entries: DirEntry[];
77 try {
78 entries = await host.listDir(dir);
79 } catch {
80 return; // empty dir / permission error: skip this subtree, keep the rest
81 }
82 entries = entries
83 .filter((e) => !(opts.hideHidden && e.name.startsWith('.')))
84 .filter((e) => e.name !== '.git')
85 .sort((a, b) => cmpSibling(a.name, a.kind, b.name, b.kind));
86 for (const e of entries) {
87 const path = dir === '' ? e.name : `${dir}/${e.name}`;
88 if (e.kind === 'dir') {
89 const expanded = state.expanded.has(path);
90 rows.push({
91 path,
92 kind: 'dir',
93 depth,
94 expanded,
95 status: statusOf(path),
96 dirDirty: dirHasChange(statusMap, opts.changedSet, path),
97 });
98 if (expanded) await walk(path, depth + 1);
99 } else {
100 rows.push({
101 path,
102 kind: 'file',
103 depth,
104 expanded: false,
105 status: statusOf(path),
106 dirDirty: false,
107 });
108 }
109 }
110 }
111}
112
113/** Next/previous visible file with a status, wrapping; undefined when none. */
114export function nextChanged(
115 rows: TreeNode[],
116 currentPath: string | undefined,
117 dir: 1 | -1,
118): string | undefined {
119 const changed = (r: TreeNode): boolean => r.kind === 'file' && r.status !== undefined;
120 if (!rows.some(changed)) return undefined;
121 const cur = currentPath === undefined ? -1 : rows.findIndex((r) => r.path === currentPath);
122 if (cur === -1) {
123 const files = rows.filter(changed);
124 return dir === 1 ? files[0]!.path : files[files.length - 1]!.path;
125 }
126 for (let step = 1; step <= rows.length; step++) {
127 const i = (((cur + dir * step) % rows.length) + rows.length) % rows.length;
128 const r = rows[i]!;
129 if (changed(r)) return r.path;
130 }
131 return undefined;
132}
133
134/** cmp_sibling (tree.rs): directories before files, then name — Rust OsStr byte
135 * order ≈ JS UTF-16 code-unit order for the ASCII names a repo tree sees. */
136function cmpSibling(aName: string, aKind: NodeKind, bName: string, bKind: NodeKind): number {
137 const dirs = (bKind === 'dir' ? 1 : 0) - (aKind === 'dir' ? 1 : 0);
138 if (dirs !== 0) return dirs;
139 return aName < bName ? -1 : aName > bName ? 1 : 0;
140}
141
142/** The path's parent ('' at the top level). */
143function parentOf(path: string): string {
144 const i = path.lastIndexOf('/');
145 return i === -1 ? '' : path.slice(0, i);
146}
147
148/** The path's final component. */
149function nameOf(path: string): string {
150 const i = path.lastIndexOf('/');
151 return i === -1 ? path : path.slice(i + 1);
152}
153
154/**
155 * dir_dirty (tree.rs dir_contains_change): any changed file lives under `dir`,
156 * over the union of working-tree status and the baseline changed-set (the Rust
157 * original chains `markers` and `changed_filter`). Component-wise prefix match
158 * (`src` is never dirtied by `src2/…`, the dir's own path excluded) — cheap,
159 * so collapsed dirs get it too.
160 */
161function dirHasChange(
162 statusMap: Map<string, GitStatus>,
163 changedSet: Map<string, GitStatus> | undefined,
164 dir: string,
165): boolean {
166 const prefix = dir + '/';
167 const hit = (map: Map<string, GitStatus>): boolean => {
168 for (const key of map.keys()) {
169 if (key !== dir && key.startsWith(prefix)) return true;
170 }
171 return false;
172 };
173 return hit(statusMap) || (changedSet !== undefined && hit(changedSet));
174}
175
176/**
177 * changed-only synthesis (tree.rs changed_only_nodes/emit_synthetic): rows come
178 * from the status map, never the filesystem, so deleted paths — and files under a
179 * deleted directory — still get rows. Only changed files and their ancestor dirs;
180 * dirs are expanded and dirty by construction; no display filter applies (the
181 * Rust emitter has none either).
182 */
183function changedOnlyRows(state: TreeState, statusMap: Map<string, GitStatus>): TreeNode[] {
184 const prefix = state.root === '' ? '' : state.root + '/';
185 const files = [...statusMap.keys()].filter((k) => k.startsWith(prefix));
186 const dirs = new Set<string>();
187 for (const f of files) {
188 let a = parentOf(f);
189 while (a !== '' && a !== state.root) {
190 dirs.add(a);
191 a = parentOf(a);
192 }
193 }
194
195 const rows: TreeNode[] = [];
196 emit(state.root, 0);
197 return rows;
198
199 function emit(parent: string, depth: number): void {
200 const children: Array<{ path: string; kind: NodeKind }> = [];
201 for (const d of dirs) if (parentOf(d) === parent) children.push({ path: d, kind: 'dir' });
202 for (const f of files) if (parentOf(f) === parent) children.push({ path: f, kind: 'file' });
203 children.sort((a, b) => cmpSibling(nameOf(a.path), a.kind, nameOf(b.path), b.kind));
204 for (const c of children) {
205 if (c.kind === 'dir') {
206 rows.push({
207 path: c.path,
208 kind: 'dir',
209 depth,
210 expanded: true,
211 status: statusMap.get(c.path),
212 dirDirty: true, // ancestor of a changed file by construction
213 });
214 emit(c.path, depth + 1);
215 } else {
216 rows.push({
217 path: c.path,
218 kind: 'file',
219 depth,
220 expanded: false,
221 status: statusMap.get(c.path),
222 dirDirty: false,
223 });
224 }
225 }
226 }
227}
228hooks/core/fuzzy.ts 107 lines1/**
2 * Fuzzy matching — case-insensitive subsequence scoring for the go-to-file finder.
3 * Port of herdr-file-viewer src/fuzzy.rs (★B). Preserves that file's ranking contract:
4 * - a query that lands inside the basename (after the last `/`) always outranks a
5 * directory-only hit (fuzzy.rs scores the directory-only case 1_000_000 worse);
6 * - shorter paths win ties (fuzzy.rs: `length_score`);
7 * - ASCII-only case folding (fuzzy.rs: `to_ascii_lowercase`);
8 * - a query that is not a subsequence does not match (`None` → `undefined`).
9 * Within a tier the score adds match-quality bonuses — prefix, path-separator/word
10 * boundary, consecutive hits, exact match — which the Rust original does not rank by.
11 */
12
13/** Directory-only hits always sort below basename hits (fuzzy.rs `1_000_000` gap). */
14const BASENAME_BONUS = 1_000_000;
15/** One unit of match quality strictly outweighs any path-length tie-break. */
16const QUALITY_SCALE = 10_000;
17/** Keeps `units * QUALITY_SCALE` below BASENAME_BONUS so the basename tier always dominates. */
18const MAX_QUALITY_UNITS = 99;
19const UNIT_PER_CHAR = 4;
20const BONUS_BOUNDARY = 8;
21const BONUS_PREFIX = 5;
22const BONUS_CONSECUTIVE = 6;
23const BONUS_EXACT = 500;
24
25const SEPARATORS = new Set(['/', '\\', '-', '_', '.', ' ', '\t', '(', '[', '{', ',', ':']);
26
27/** ASCII-only lowercase fold: `A`-`Z` only, so non-ASCII compares exactly (fuzzy.rs). */
28function foldAscii(ch: string): string {
29 const c = ch.charCodeAt(0);
30 return c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : ch;
31}
32
33function foldAsciiAll(s: string): string[] {
34 return Array.from(s, foldAscii);
35}
36
37function isSubsequence(pattern: readonly string[], text: readonly string[]): boolean {
38 let i = 0;
39 for (const ch of text) {
40 if (i < pattern.length && pattern[i] === ch) i++;
41 if (i === pattern.length) return true;
42 }
43 return pattern.length === 0;
44}
45
46/** Word boundary at `idx`: start of target, after a path/word separator, or camelCase. */
47function isBoundary(orig: readonly string[], idx: number): boolean {
48 if (idx === 0) return true;
49 const prev = orig[idx - 1];
50 if (SEPARATORS.has(prev)) return true;
51 const p = prev.charCodeAt(0);
52 const c = orig[idx].charCodeAt(0);
53 const prevWord = (p >= 97 && p <= 122) || (p >= 48 && p <= 57);
54 return prevWord && c >= 65 && c <= 90;
55}
56
57/**
58 * Score pattern against target; `undefined` = no match. Higher is better.
59 * Case-insensitive (ASCII fold); a full match of the basename earns the basename tier.
60 */
61export function fuzzyScore(pattern: string, target: string): number | undefined {
62 if (pattern.length === 0) return 0;
63 const p = foldAsciiAll(pattern);
64 const orig = Array.from(target);
65 const t = orig.map(foldAscii);
66 let units = 0;
67 let ti = 0;
68 let prevIdx = -2;
69 for (let pi = 0; pi < p.length; pi++) {
70 let idx = ti;
71 while (idx < t.length && t[idx] !== p[pi]) idx++;
72 if (idx >= t.length) return undefined;
73 units += UNIT_PER_CHAR;
74 if (isBoundary(orig, idx)) units += BONUS_BOUNDARY;
75 if (pi === 0 && idx === 0) units += BONUS_PREFIX;
76 if (idx === prevIdx + 1) units += BONUS_CONSECUTIVE;
77 prevIdx = idx;
78 ti = idx + 1;
79 }
80 if (p.length === t.length && p.every((ch, i) => ch === t[i])) units += BONUS_EXACT;
81 const sep = target.lastIndexOf('/');
82 const basenameHit = isSubsequence(p, foldAsciiAll(sep >= 0 ? target.slice(sep + 1) : target));
83 units = Math.min(units, MAX_QUALITY_UNITS);
84 const lengthPenalty = Math.min(target.length, QUALITY_SCALE - 1);
85 return (basenameHit ? BASENAME_BONUS : 0) + units * QUALITY_SCALE - lengthPenalty;
86}
87
88/**
89 * Filter+rank items by fuzzy score against `key(item)`; best first.
90 * Non-matches are dropped, equal scores keep their original order, and an empty
91 * pattern yields no results (fuzzy.rs `match_and_rank`: empty query → empty Vec, AC-2).
92 */
93export function fuzzyFilter<T>(
94 pattern: string,
95 items: readonly T[],
96 key: (t: T) => string,
97): Array<{ item: T; score: number }> {
98 if (pattern.length === 0) return [];
99 const scored: Array<{ item: T; score: number; index: number }> = [];
100 for (let i = 0; i < items.length; i++) {
101 const score = fuzzyScore(pattern, key(items[i]));
102 if (score !== undefined) scored.push({ item: items[i], score, index: i });
103 }
104 scored.sort((a, b) => b.score - a.score || a.index - b.index);
105 return scored.map(({ item, score }) => ({ item, score }));
106}
107