SLOPSHOPPER

file-viewer

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

newpaneguardcommandprocesstimer
v0.1.0NOASSERTIONupdated 2026-10-09raincfhnj/plugin-file-viewer/adapters/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-viewer
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ f: find v: view n: next p: prev b: base HEAD │ ┃ ┌──────────────────┐ ┌────────────────────── ⏺ Read(src/auth.ts) │ ┃ │app │ │(nothing selected) · c ⎿ Read 6 lines │ ┃ │ README.md │ │ ⏺ Update(src/auth.ts) │ ┃ │ package.json│ └────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ src │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ │ ┃ │ │ › /files │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
f: find v: view n: next p: prev b: base HEAD c: changed l: t ┌──────────────────┐ ┌────────────────────────────────────── │app │ │(nothing selected) · code │ README.md │ │ │ package.json│ └────────────────────────────────────── │ src │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └──────────────────┘ 1-3/3 rows · no git repo
README

file-viewer — Claude Code mod (★C adapter)

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.

Requirements

  • Claude Code ≥ 2.1.287 (mods are early access; this adapter was validated and tested against 2.1.293). Upgrade with claude update.
  • A terminal; the pane opens at any width because a person asked for it (/files), and withdraws itself if the engine reports isPlaced: false.

Run it

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.

Layout

.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.

Keys

KeyButtonAction
ff: findFuzzy-find a file (Input, autoFocus); Enter selects the best match
vv: viewCycle diff → fullDiff → code/markdown for the selected file
nn: nextNext changed file
pp: prevPrevious changed file
bb: HEAD / b: BaseFlip the diff baseline; saved per repository
cc: changed / c: all filesChanged-only filter; saved per repository
l`l: tree\file / l: file\tree`Swap the split layout (tree ↔ content sides); saved per repository
rr: reloadRefresh 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 ~.

Behaviour notes

  • Data flow: one module-level model. 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).
  • Refresh: 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.
  • Repo root: 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 safety: every git call is ['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.
  • Windows: isWindows is derived from the session cwd (C:/… or backslashes); SAFE_GIT_FLAGS then uses core.hooksPath=NUL.
  • Preferences: baseline, changedOnly and the changed-file view preference live in $.store under file-viewer:<repo-root>:<name>.
  • Degrade: a failed refresh clears the tree and puts the reason on the status line; ui.render carries a .catch that draws a one-line notice, and every gating hook has one (required by claude plugin validate --strict).

Development

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

Known limitations

  • 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.
  • Content is windowed by lines. The drawn tree always fits the pane (that is what keeps ↑/↓ 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>.
  • Markdown above the window is truncated by the 10,000-character element cap (… [truncated]); a markdown line wider than its column wraps and can push the drawing one row past the pane.
  • Dotfiles are hidden (core's DEFAULT_TREE_OPTIONS.hideHidden); there is no toggle, so /files .github/… reports hidden path.
  • No mouse selection, no annotations, no editor hand-off — those are herdr features (see the reference repository) and are out of scope for v0.1.
  • Content above 200,000 characters is cut; binary files show binary file.
  • The pane tracks the repository root for the whole session; /cd into another directory does not re-root it (start a new session or /files again after moving).
Source 9 files
hooks/register.ts 139 lines
1import 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
139
hooks/core/types.ts 107 lines
1/**
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}
107
hooks/host.ts 95 lines
1import 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}
95
hooks/pane.ts 498 lines
1import 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}
498
hooks/model.ts 655 lines
1import 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};
655
hooks/core/git.ts 315 lines
1/**
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}
315
hooks/core/view_policy.ts 56 lines
1/**
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}
56
hooks/core/tree.ts 228 lines
1/**
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}
228
hooks/core/fuzzy.ts 107 lines
1/**
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