tmux-ide 2.6.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +194 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +110 -2
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, io-free helpers for the built-in git diff panel (M18.3; grouped +
|
|
3
|
+
* stage-aware M24.5).
|
|
4
|
+
*
|
|
5
|
+
* Like {@link ./editor-buffer.ts}, this imports NOTHING from @opentui/core: the
|
|
6
|
+
* app runs the git subprocesses (async execFile) and reads untracked files, but
|
|
7
|
+
* the PARSING and coordinate math live here so they unit-test as tables under
|
|
8
|
+
* the Node/vitest runner while app.tsx keeps the OpenTUI wiring.
|
|
9
|
+
*
|
|
10
|
+
* Concerns: (1) parse `git status --porcelain` into display-ready entries —
|
|
11
|
+
* flat ({@link parseStatusPorcelain}, legacy) or split into Staged / Unstaged /
|
|
12
|
+
* Untracked components ({@link parseStatusGroups}: an `MM` file yields BOTH a
|
|
13
|
+
* staged and an unstaged row, each diffing its own side of the index); (2) lay
|
|
14
|
+
* the grouped entries out as list ROWS (section headers + files) whose math is
|
|
15
|
+
* shared by the render and the mouse router ({@link buildDiffRows}); (3) merge
|
|
16
|
+
* `git diff --numstat` ± counts into the entries; (4) classify each `git diff`
|
|
17
|
+
* line so the renderer can color it; (5) hunk coordinate math — `]`/`[` jump
|
|
18
|
+
* targets and the `^e` open-editor-at-line target parsed from `@@ -a,b +c,d`;
|
|
19
|
+
* (6) turn an untracked file's contents into an all-additions pseudo-diff (git
|
|
20
|
+
* diff prints nothing for untracked paths).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One changed file from `git status --porcelain`, display-ready. */
|
|
24
|
+
export interface StatusEntry {
|
|
25
|
+
/** Single status letter for the list: M(odified) · A(dded) · D(eleted) ·
|
|
26
|
+
* R(enamed) · ?(untracked) · etc. Worktree state wins over index state so a
|
|
27
|
+
* file staged-then-edited still reads as M. */
|
|
28
|
+
status: string;
|
|
29
|
+
/** Repo-relative path (the NEW path for renames). */
|
|
30
|
+
path: string;
|
|
31
|
+
/** True when the index carries a change (a staged component exists). */
|
|
32
|
+
staged: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PURE — parse porcelain v1 output. Each line is `XY<space>PATH`, where X is the
|
|
37
|
+
* index state and Y the worktree state; renames/copies render as `orig -> new`.
|
|
38
|
+
* Untracked files come through as `??`. Malformed/short lines are skipped.
|
|
39
|
+
*/
|
|
40
|
+
export function parseStatusPorcelain(out: string): StatusEntry[] {
|
|
41
|
+
const entries: StatusEntry[] = [];
|
|
42
|
+
for (const line of out.split("\n")) {
|
|
43
|
+
if (line.length < 4) continue;
|
|
44
|
+
const index = line[0]!;
|
|
45
|
+
const work = line[1]!;
|
|
46
|
+
let rest = line.slice(3);
|
|
47
|
+
const arrow = rest.indexOf(" -> ");
|
|
48
|
+
if (arrow !== -1) rest = rest.slice(arrow + 4);
|
|
49
|
+
const untracked = index === "?" && work === "?";
|
|
50
|
+
const status = untracked ? "?" : work !== " " ? work : index;
|
|
51
|
+
entries.push({ status, path: rest, staged: !untracked && index !== " " });
|
|
52
|
+
}
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How a unified-diff line renders: added / removed / hunk header / file-meta
|
|
57
|
+
* (diff/index/+++/---/mode/rename) / plain context. */
|
|
58
|
+
export type DiffLineKind = "add" | "del" | "hunk" | "meta" | "context";
|
|
59
|
+
|
|
60
|
+
/** One classified diff line: its kind and its raw text (leading sigil kept). */
|
|
61
|
+
export interface DiffLine {
|
|
62
|
+
kind: DiffLineKind;
|
|
63
|
+
text: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const META_PREFIXES = [
|
|
67
|
+
"diff ",
|
|
68
|
+
"index ",
|
|
69
|
+
"new file",
|
|
70
|
+
"deleted file",
|
|
71
|
+
"old mode",
|
|
72
|
+
"new mode",
|
|
73
|
+
"similarity ",
|
|
74
|
+
"dissimilarity ",
|
|
75
|
+
"rename ",
|
|
76
|
+
"copy ",
|
|
77
|
+
"Binary ",
|
|
78
|
+
"\\ No newline",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* PURE — classify one raw diff line. Order matters: the `+++`/`---` file headers
|
|
83
|
+
* must be caught as meta BEFORE the bare `+`/`-` add/del checks.
|
|
84
|
+
*/
|
|
85
|
+
export function classifyDiffLine(line: string): DiffLineKind {
|
|
86
|
+
if (line.startsWith("@@")) return "hunk";
|
|
87
|
+
if (line.startsWith("+++") || line.startsWith("---")) return "meta";
|
|
88
|
+
for (const p of META_PREFIXES) if (line.startsWith(p)) return "meta";
|
|
89
|
+
if (line.startsWith("+")) return "add";
|
|
90
|
+
if (line.startsWith("-")) return "del";
|
|
91
|
+
return "context";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* PURE — split a `git diff --no-color` blob into classified lines, dropping the
|
|
96
|
+
* single trailing empty produced by the final newline.
|
|
97
|
+
*/
|
|
98
|
+
export function classifyDiff(diff: string): DiffLine[] {
|
|
99
|
+
if (!diff) return [];
|
|
100
|
+
const lines = diff.split("\n");
|
|
101
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
102
|
+
return lines.map((text) => ({ kind: classifyDiffLine(text), text }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* PURE — render an untracked file's contents as an all-additions pseudo-diff
|
|
107
|
+
* (git diff emits nothing for untracked paths). Each content line is prefixed
|
|
108
|
+
* with `+` so {@link classifyDiff} colors it as an addition.
|
|
109
|
+
*/
|
|
110
|
+
export function untrackedDiffText(content: string): string {
|
|
111
|
+
const lines = content.split("\n");
|
|
112
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
113
|
+
return lines.map((l) => "+" + l).join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** PURE — clamp a selection index into `[0, count)` (0 when the list is empty). */
|
|
117
|
+
export function clampSel(sel: number, count: number): number {
|
|
118
|
+
if (count <= 0) return 0;
|
|
119
|
+
return Math.max(0, Math.min(sel, count - 1));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Grouped list (M24.5) ─────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** Which section of the grouped list an entry belongs to. */
|
|
125
|
+
export type DiffGroup = "staged" | "unstaged" | "untracked";
|
|
126
|
+
|
|
127
|
+
/** One stage-aware changed-file entry: a porcelain XY line is SPLIT into its
|
|
128
|
+
* index and worktree components, so an `MM` file yields two entries — the
|
|
129
|
+
* staged one diffs `--cached`, the unstaged one diffs the worktree. */
|
|
130
|
+
export interface DiffEntry {
|
|
131
|
+
group: DiffGroup;
|
|
132
|
+
/** Display letter for THIS component: the X (index) letter for staged rows,
|
|
133
|
+
* the Y (worktree) letter for unstaged rows, `?` for untracked. */
|
|
134
|
+
status: string;
|
|
135
|
+
/** Repo-relative path (the NEW path for renames). */
|
|
136
|
+
path: string;
|
|
137
|
+
/** ± line counts from `git diff --numstat` (untracked: the file's line
|
|
138
|
+
* count). `null` until loaded, and for binary/unreadable files. */
|
|
139
|
+
additions: number | null;
|
|
140
|
+
deletions: number | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* PURE — parse porcelain v1 output into grouped, stage-aware entries. Each
|
|
145
|
+
* `XY PATH` line contributes: a STAGED entry when X is a real index state, an
|
|
146
|
+
* UNSTAGED entry when Y is a real worktree state, or one UNTRACKED entry for
|
|
147
|
+
* `??`. Ignored (`!!`) and malformed lines are skipped. Counts start `null`
|
|
148
|
+
* ({@link applyCounts} fills them).
|
|
149
|
+
*/
|
|
150
|
+
export function parseStatusGroups(out: string): DiffEntry[] {
|
|
151
|
+
const entries: DiffEntry[] = [];
|
|
152
|
+
for (const line of out.split("\n")) {
|
|
153
|
+
if (line.length < 4) continue;
|
|
154
|
+
const index = line[0]!;
|
|
155
|
+
const work = line[1]!;
|
|
156
|
+
let rest = line.slice(3);
|
|
157
|
+
const arrow = rest.indexOf(" -> ");
|
|
158
|
+
if (arrow !== -1) rest = rest.slice(arrow + 4);
|
|
159
|
+
if (index === "!" && work === "!") continue;
|
|
160
|
+
if (index === "?" && work === "?") {
|
|
161
|
+
entries.push({
|
|
162
|
+
group: "untracked",
|
|
163
|
+
status: "?",
|
|
164
|
+
path: rest,
|
|
165
|
+
additions: null,
|
|
166
|
+
deletions: null,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (index !== " " && index !== "?")
|
|
171
|
+
entries.push({
|
|
172
|
+
group: "staged",
|
|
173
|
+
status: index,
|
|
174
|
+
path: rest,
|
|
175
|
+
additions: null,
|
|
176
|
+
deletions: null,
|
|
177
|
+
});
|
|
178
|
+
if (work !== " " && work !== "?")
|
|
179
|
+
entries.push({
|
|
180
|
+
group: "unstaged",
|
|
181
|
+
status: work,
|
|
182
|
+
path: rest,
|
|
183
|
+
additions: null,
|
|
184
|
+
deletions: null,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** PURE — narrow entries to paths containing `query` (case-insensitive
|
|
191
|
+
* substring). An empty/blank query returns the input unchanged. */
|
|
192
|
+
export function filterEntries(entries: DiffEntry[], query: string): DiffEntry[] {
|
|
193
|
+
const q = query.trim().toLowerCase();
|
|
194
|
+
if (!q) return entries;
|
|
195
|
+
return entries.filter((e) => e.path.toLowerCase().includes(q));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One row of the grouped file list: a non-selectable section header or a file
|
|
199
|
+
* entry carrying its index into the flat selectable-file order. */
|
|
200
|
+
export type DiffRow =
|
|
201
|
+
| { kind: "header"; group: DiffGroup; label: string }
|
|
202
|
+
| { kind: "file"; entry: DiffEntry; fileIndex: number };
|
|
203
|
+
|
|
204
|
+
const GROUP_ORDER: { group: DiffGroup; label: string }[] = [
|
|
205
|
+
{ group: "staged", label: "Staged" },
|
|
206
|
+
{ group: "unstaged", label: "Unstaged" },
|
|
207
|
+
{ group: "untracked", label: "Untracked" },
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PURE — lay grouped entries out as list rows: per-group `Label (n)` headers
|
|
212
|
+
* (only for non-empty groups, in Staged → Unstaged → Untracked order) followed
|
|
213
|
+
* by that group's files. `files` is the flat selectable order — `fileIndex` on
|
|
214
|
+
* each file row indexes it, so the selection model stays a plain integer while
|
|
215
|
+
* the render/hit math walk ROWS. Shared render↔router, one source of truth.
|
|
216
|
+
*/
|
|
217
|
+
export function buildDiffRows(entries: DiffEntry[]): { rows: DiffRow[]; files: DiffEntry[] } {
|
|
218
|
+
const rows: DiffRow[] = [];
|
|
219
|
+
const files: DiffEntry[] = [];
|
|
220
|
+
for (const { group, label } of GROUP_ORDER) {
|
|
221
|
+
const members = entries.filter((e) => e.group === group);
|
|
222
|
+
if (members.length === 0) continue;
|
|
223
|
+
rows.push({ kind: "header", group, label: `${label} (${members.length})` });
|
|
224
|
+
for (const entry of members) {
|
|
225
|
+
rows.push({ kind: "file", entry, fileIndex: files.length });
|
|
226
|
+
files.push(entry);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { rows, files };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** PURE — the row index of selectable file `fileIndex` (for keeping the
|
|
233
|
+
* selection in view when the list scrolls over ROWS). -1 when absent. */
|
|
234
|
+
export function rowIndexOfFile(rows: DiffRow[], fileIndex: number): number {
|
|
235
|
+
return rows.findIndex((r) => r.kind === "file" && r.fileIndex === fileIndex);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── numstat (M24.5) ──────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/** ± line counts for one file. */
|
|
241
|
+
export interface Numstat {
|
|
242
|
+
additions: number;
|
|
243
|
+
deletions: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Resolve a numstat path cell to the NEW path: renames print either
|
|
247
|
+
* `pre{old => new}post` (common-prefix brace form) or a bare `old => new`. */
|
|
248
|
+
function numstatPath(raw: string): string {
|
|
249
|
+
const brace = raw.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
|
250
|
+
if (brace) return `${brace[1]}${brace[3]}${brace[4]}`;
|
|
251
|
+
const arrow = raw.indexOf(" => ");
|
|
252
|
+
if (arrow !== -1) return raw.slice(arrow + 4);
|
|
253
|
+
return raw;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* PURE — parse `git diff --numstat` output (`added<TAB>deleted<TAB>path`) into
|
|
258
|
+
* a path → counts map. Binary files print `- - path` and are SKIPPED (their
|
|
259
|
+
* entries keep `null` counts); rename cells resolve to the new path.
|
|
260
|
+
*/
|
|
261
|
+
export function parseNumstat(out: string): Map<string, Numstat> {
|
|
262
|
+
const counts = new Map<string, Numstat>();
|
|
263
|
+
for (const line of out.split("\n")) {
|
|
264
|
+
if (!line) continue;
|
|
265
|
+
const parts = line.split("\t");
|
|
266
|
+
if (parts.length < 3) continue;
|
|
267
|
+
const additions = parseInt(parts[0]!, 10);
|
|
268
|
+
const deletions = parseInt(parts[1]!, 10);
|
|
269
|
+
if (Number.isNaN(additions) || Number.isNaN(deletions)) continue; // binary: "-"
|
|
270
|
+
counts.set(numstatPath(parts.slice(2).join("\t")), { additions, deletions });
|
|
271
|
+
}
|
|
272
|
+
return counts;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** PURE — the line count an untracked file shows as its `+` count: content
|
|
276
|
+
* lines, with the single trailing newline's empty not counted (mirrors
|
|
277
|
+
* {@link untrackedDiffText}); empty content counts 0. */
|
|
278
|
+
export function untrackedLineCount(content: string): number {
|
|
279
|
+
if (content === "") return 0;
|
|
280
|
+
const lines = content.split("\n");
|
|
281
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
282
|
+
return lines.length;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* PURE — merge per-group counts into entries (staged entries read the
|
|
287
|
+
* `--cached` numstat, unstaged the worktree numstat, untracked the line-count
|
|
288
|
+
* map). Entries without a match keep `null` counts (binary/unreadable).
|
|
289
|
+
*/
|
|
290
|
+
export function applyCounts(
|
|
291
|
+
entries: DiffEntry[],
|
|
292
|
+
staged: Map<string, Numstat>,
|
|
293
|
+
unstaged: Map<string, Numstat>,
|
|
294
|
+
untrackedLines: Map<string, number>,
|
|
295
|
+
): DiffEntry[] {
|
|
296
|
+
return entries.map((e) => {
|
|
297
|
+
if (e.group === "untracked") {
|
|
298
|
+
const lines = untrackedLines.get(e.path);
|
|
299
|
+
return lines === undefined ? e : { ...e, additions: lines, deletions: 0 };
|
|
300
|
+
}
|
|
301
|
+
const n = (e.group === "staged" ? staged : unstaged).get(e.path);
|
|
302
|
+
return n === undefined ? e : { ...e, additions: n.additions, deletions: n.deletions };
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** PURE — sum the loaded ± counts across entries (nulls contribute 0), for the
|
|
307
|
+
* header totals. */
|
|
308
|
+
export function totalCounts(entries: DiffEntry[]): { additions: number; deletions: number } {
|
|
309
|
+
let additions = 0;
|
|
310
|
+
let deletions = 0;
|
|
311
|
+
for (const e of entries) {
|
|
312
|
+
additions += e.additions ?? 0;
|
|
313
|
+
deletions += e.deletions ?? 0;
|
|
314
|
+
}
|
|
315
|
+
return { additions, deletions };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Hunk math (M24.5) ────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/** A parsed `@@ -a,b +c,d @@` header (counts default 1 when omitted). */
|
|
321
|
+
export interface HunkHeader {
|
|
322
|
+
oldStart: number;
|
|
323
|
+
oldCount: number;
|
|
324
|
+
newStart: number;
|
|
325
|
+
newCount: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** PURE — parse a hunk header line; null for anything else. */
|
|
329
|
+
export function parseHunkHeader(text: string): HunkHeader | null {
|
|
330
|
+
const m = text.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
331
|
+
if (!m) return null;
|
|
332
|
+
return {
|
|
333
|
+
oldStart: parseInt(m[1]!, 10),
|
|
334
|
+
oldCount: m[2] !== undefined ? parseInt(m[2], 10) : 1,
|
|
335
|
+
newStart: parseInt(m[3]!, 10),
|
|
336
|
+
newCount: m[4] !== undefined ? parseInt(m[4], 10) : 1,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** PURE — the indices of all hunk-header lines in a classified diff. */
|
|
341
|
+
export function hunkLineIndices(lines: DiffLine[]): number[] {
|
|
342
|
+
const out: number[] = [];
|
|
343
|
+
for (let i = 0; i < lines.length; i++) if (lines[i]!.kind === "hunk") out.push(i);
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** PURE — the `]`/`[` jump target: the first hunk-line index strictly after
|
|
348
|
+
* (`dir` 1) or before (`dir` -1) the current view top. null = no further hunk
|
|
349
|
+
* in that direction (the view stays put). */
|
|
350
|
+
export function nextHunkTop(lines: DiffLine[], top: number, dir: 1 | -1): number | null {
|
|
351
|
+
const hunks = hunkLineIndices(lines);
|
|
352
|
+
if (dir === 1) {
|
|
353
|
+
for (const i of hunks) if (i > top) return i;
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
for (let j = hunks.length - 1; j >= 0; j--) if (hunks[j]! < top) return hunks[j]!;
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* PURE — the `^e` open-editor target: the 0-based NEW-file line of the first
|
|
362
|
+
* changed (add/del) line of the SELECTED hunk — the hunk whose header is at or
|
|
363
|
+
* nearest above the current view top (the first hunk when the view is above
|
|
364
|
+
* them all). Walks from `+c` counting context/add lines; a del line targets
|
|
365
|
+
* the new-file position where the removal happened. Falls back to `c` itself
|
|
366
|
+
* for a hunk with no explicit ± lines, and null when the diff has no hunks
|
|
367
|
+
* (binary/meta-only/pseudo-diff — the caller opens at 0).
|
|
368
|
+
*/
|
|
369
|
+
export function hunkEditTarget(lines: DiffLine[], top: number): number | null {
|
|
370
|
+
const hunks = hunkLineIndices(lines);
|
|
371
|
+
if (hunks.length === 0) return null;
|
|
372
|
+
let h = hunks[0]!;
|
|
373
|
+
for (const i of hunks) {
|
|
374
|
+
if (i <= top) h = i;
|
|
375
|
+
else break;
|
|
376
|
+
}
|
|
377
|
+
const hdr = parseHunkHeader(lines[h]!.text);
|
|
378
|
+
if (!hdr) return null;
|
|
379
|
+
let newLine = hdr.newStart; // 1-based
|
|
380
|
+
for (let i = h + 1; i < lines.length; i++) {
|
|
381
|
+
const kind = lines[i]!.kind;
|
|
382
|
+
if (kind === "hunk" || kind === "meta") break;
|
|
383
|
+
if (kind === "add" || kind === "del") return Math.max(0, newLine - 1);
|
|
384
|
+
newLine++; // context
|
|
385
|
+
}
|
|
386
|
+
return Math.max(0, hdr.newStart - 1);
|
|
387
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, io-free helpers for the built-in file editor panel (M18.2).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately imports NOTHING from @opentui/core: the editing ENGINE is a
|
|
5
|
+
* native `EditBuffer` (bun:ffi), which cannot load under Node/vitest. Everything
|
|
6
|
+
* that can be unit-tested — binary sniffing, read-only classification, the
|
|
7
|
+
* line-number gutter, viewport slicing, and click→cursor coordinate math —
|
|
8
|
+
* lives here so it runs green under the Node test runner while app.tsx keeps
|
|
9
|
+
* the bun-only EditBuffer wiring.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Files at or above this byte length open read-only (no full-buffer edits on
|
|
13
|
+
* the render loop). */
|
|
14
|
+
export const MAX_EDITABLE_BYTES = 1_000_000;
|
|
15
|
+
/** How many leading bytes we sniff for a NUL before calling a file binary. */
|
|
16
|
+
export const BINARY_SNIFF_BYTES = 8000;
|
|
17
|
+
|
|
18
|
+
/** NUL-byte sniff: a single 0x00 in the leading window means "not text". */
|
|
19
|
+
export function isBinary(bytes: Uint8Array): boolean {
|
|
20
|
+
const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
21
|
+
for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ReadOnlyReason = "binary" | "large" | null;
|
|
26
|
+
|
|
27
|
+
/** Decide whether a freshly-read file is editable, and why not. */
|
|
28
|
+
export function classifyFile(byteLength: number, binary: boolean): ReadOnlyReason {
|
|
29
|
+
if (binary) return "binary";
|
|
30
|
+
if (byteLength >= MAX_EDITABLE_BYTES) return "large";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Human banner for a read-only reason (null = editable, no banner). */
|
|
35
|
+
export function readOnlyBanner(reason: ReadOnlyReason): string | null {
|
|
36
|
+
if (reason === "binary") return "read-only · binary file (null byte detected)";
|
|
37
|
+
if (reason === "large") return `read-only · file ≥ ${MAX_EDITABLE_BYTES / 1_000_000} MB`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Render a binary/undisplayable buffer as a safe ASCII preview: control and
|
|
42
|
+
* high bytes become "·" so the rope never carries NULs and the panel stays
|
|
43
|
+
* legible. Newlines/tabs are preserved so line structure survives. */
|
|
44
|
+
export function sanitizeForDisplay(bytes: Uint8Array): string {
|
|
45
|
+
let out = "";
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
const b = bytes[i]!;
|
|
48
|
+
if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
|
|
49
|
+
out += String.fromCharCode(b);
|
|
50
|
+
} else {
|
|
51
|
+
out += "·";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Total width of the line-number gutter, including its trailing space. */
|
|
58
|
+
export function gutterWidth(totalLines: number): number {
|
|
59
|
+
return Math.max(3, String(Math.max(1, totalLines)).length) + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Right-aligned line number filling `width` (last column is the separator). */
|
|
63
|
+
export function formatGutter(lineNumber: number, width: number): string {
|
|
64
|
+
return String(lineNumber).padStart(width - 1, " ") + " ";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Clamp the top (first-visible) line so the viewport never scrolls past the
|
|
68
|
+
* last screenful. `rows` is the number of text rows on screen. */
|
|
69
|
+
export function clampTop(top: number, totalLines: number, rows: number): number {
|
|
70
|
+
const max = Math.max(0, totalLines - rows);
|
|
71
|
+
if (top < 0) return 0;
|
|
72
|
+
if (top > max) return max;
|
|
73
|
+
return top;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The [start,end) line range visible for a given scroll position. */
|
|
77
|
+
export function visibleRange(
|
|
78
|
+
totalLines: number,
|
|
79
|
+
top: number,
|
|
80
|
+
rows: number,
|
|
81
|
+
): { start: number; end: number } {
|
|
82
|
+
const start = clampTop(top, totalLines, rows);
|
|
83
|
+
return { start, end: Math.min(totalLines, start + rows) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ensure the cursor line sits inside [top, top+rows); returns the new top. */
|
|
87
|
+
export function scrollToCursor(
|
|
88
|
+
cursorLine: number,
|
|
89
|
+
top: number,
|
|
90
|
+
rows: number,
|
|
91
|
+
totalLines: number,
|
|
92
|
+
): number {
|
|
93
|
+
let next = top;
|
|
94
|
+
if (cursorLine < top) next = cursorLine;
|
|
95
|
+
else if (cursorLine >= top + rows) next = cursorLine - rows + 1;
|
|
96
|
+
return clampTop(next, totalLines, rows);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Map a click at content-relative coordinates to a buffer (line, col).
|
|
101
|
+
* `contentY` is rows below the editor header; `cx` is columns right of the
|
|
102
|
+
* sidebar (gutter included). Both results are clamped to the buffer.
|
|
103
|
+
*/
|
|
104
|
+
export function clickToCursor(params: {
|
|
105
|
+
cx: number;
|
|
106
|
+
contentY: number;
|
|
107
|
+
gutterW: number;
|
|
108
|
+
top: number;
|
|
109
|
+
lines: string[];
|
|
110
|
+
}): { line: number; col: number } {
|
|
111
|
+
const { cx, contentY, gutterW, top, lines } = params;
|
|
112
|
+
const total = Math.max(1, lines.length);
|
|
113
|
+
const line = Math.max(0, Math.min(total - 1, top + contentY));
|
|
114
|
+
const lineText = lines[line] ?? "";
|
|
115
|
+
const col = Math.max(0, Math.min(lineText.length, cx - gutterW));
|
|
116
|
+
return { line, col };
|
|
117
|
+
}
|