opencode-git-plugin 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/tui.js +447 -152
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,6 +82,7 @@ Do not add the plugin to `opencode.json(c)`; that file is for server plugins and
82
82
  | Option | Type | Default | Description |
83
83
  | --- | --- | --- | --- |
84
84
  | `intervalMs` | `number` | `4000` | Refresh interval in milliseconds; values below `1000` are clamped. |
85
+ | `folderTree` | `string` | `"none"` | File list style: `"none"` (full paths), `"comfortable"` (indented tree, 2 cols/level), `"compact"` (tree, 1 col/level), or `"minimal"` (plain indentation). |
85
86
 
86
87
  ## Usage
87
88
 
@@ -92,6 +93,7 @@ Do not add the plugin to `opencode.json(c)`; that file is for server plugins and
92
93
  - Click a row to open its diff; click the branch name to enter stage mode (every clicked file toggles staged/unstaged; branch click or `ESC` exits)
93
94
  - Clicking the prompt input exits list mode so typing is never hijacked
94
95
  - Staging also via palette (`Git Panel: Stage/Unstage File`)
96
+ - With `folderTree` enabled, clicking a folder collapses/expands it; in stage mode, clicking a folder stages/unstages everything inside it (unstages only when all of its files are already staged)
95
97
 
96
98
  ### Diff panel
97
99
 
@@ -110,7 +112,7 @@ Do not add the plugin to `opencode.json(c)`; that file is for server plugins and
110
112
 
111
113
  - `Loading…` while fetching
112
114
  - `Working tree clean` when there is nothing to show (header stats hidden)
113
- - `Empty repo — no commits yet` before the first commit
115
+ - `Empty repo — no commits yet` before the first commit when nothing is staged or untracked (files still list normally once they exist)
114
116
  - `Not a git repository` outside a repo, plus raw fetch errors
115
117
 
116
118
  ## Development
package/dist/tui.js CHANGED
@@ -24,6 +24,11 @@ function intervalMs(ctx) {
24
24
  return DEFAULT_INTERVAL_MS;
25
25
  return Math.max(MIN_INTERVAL_MS, Math.floor(raw));
26
26
  }
27
+ var TREE_STYLES = ["none", "comfortable", "compact", "minimal"];
28
+ function folderTree(ctx) {
29
+ const raw = ctx.options.folderTree;
30
+ return TREE_STYLES.includes(raw) ? raw : "none";
31
+ }
27
32
  // src/git/diff-parse.ts
28
33
  var DIFF_PREAMBLE_RE = /^(diff --git |index |new file mode|deleted file mode|similarity index|rename from|rename to|old mode|new mode)/;
29
34
  var HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@ ?(.*)$/;
@@ -59,17 +64,95 @@ function parseDiff(patch) {
59
64
  return out;
60
65
  }
61
66
  // src/git/porcelain.ts
62
- function parseStagedPorcelain(porcelain) {
63
- const staged = new Set;
67
+ var NO_COUNTS = { additions: 0, deletions: 0 };
68
+ function parsePorcelainRecords(porcelain) {
69
+ const out = [];
64
70
  for (const record of porcelain.split("\x00")) {
65
71
  if (record.length < 4)
66
72
  continue;
67
- const indexStatus = record[0];
68
- if (indexStatus === " " || indexStatus === "?")
73
+ out.push({ x: record[0], y: record[1], file: record.slice(3) });
74
+ }
75
+ return out;
76
+ }
77
+ var isStaged = (r) => r.x !== " " && r.x !== "?";
78
+ function parseStagedPorcelain(porcelain) {
79
+ return new Set(parsePorcelainRecords(porcelain).filter(isStaged).map((r) => r.file));
80
+ }
81
+ function porcelainChangeStatus(x, y) {
82
+ if (x === "?" || y === "?")
83
+ return "added";
84
+ if (x === "D" || y === "D")
85
+ return "deleted";
86
+ if (x === "A")
87
+ return "added";
88
+ return "modified";
89
+ }
90
+ function parseNumstat(output) {
91
+ const counts = new Map;
92
+ for (const record of output.split("\x00")) {
93
+ if (!record)
69
94
  continue;
70
- staged.add(record.slice(3));
95
+ const firstTab = record.indexOf("\t");
96
+ if (firstTab < 0)
97
+ continue;
98
+ const secondTab = record.indexOf("\t", firstTab + 1);
99
+ if (secondTab < 0)
100
+ continue;
101
+ const additions = Number(record.slice(0, firstTab));
102
+ const deletions = Number(record.slice(firstTab + 1, secondTab));
103
+ const file = record.slice(secondTab + 1);
104
+ if (!file)
105
+ continue;
106
+ counts.set(file, {
107
+ additions: Number.isFinite(additions) && additions >= 0 ? additions : 0,
108
+ deletions: Number.isFinite(deletions) && deletions >= 0 ? deletions : 0
109
+ });
71
110
  }
72
- return staged;
111
+ return counts;
112
+ }
113
+ var countsFor = (counts, file) => counts.get(file) ?? NO_COUNTS;
114
+ function buildFallbackFiles(porcelain, cachedNumstat, worktreeNumstat) {
115
+ const cached = parseNumstat(cachedNumstat);
116
+ const worktree = parseNumstat(worktreeNumstat);
117
+ return parsePorcelainRecords(porcelain).map((r) => {
118
+ const c = isStaged(r) ? countsFor(cached, r.file) : NO_COUNTS;
119
+ const w = countsFor(worktree, r.file);
120
+ return {
121
+ file: r.file,
122
+ status: porcelainChangeStatus(r.x, r.y),
123
+ additions: c.additions + w.additions,
124
+ deletions: c.deletions + w.deletions
125
+ };
126
+ }).sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
127
+ }
128
+
129
+ // src/git/fallback.ts
130
+ function cleanBranchName(output) {
131
+ const name = output.split(`
132
+ `, 1)[0]?.trim() ?? "";
133
+ return name === "" || name === "HEAD" ? "" : name;
134
+ }
135
+ async function fetchWorkingCopyFallback(git) {
136
+ const statusRes = await git.run("status --porcelain=v1 -z -uall --no-renames");
137
+ if (statusRes.exit !== 0)
138
+ throw new Error("Not a git repository");
139
+ const [cachedRes, worktreeRes, headRes, symRes] = await Promise.all([
140
+ git.run("diff --cached --numstat -z --no-renames"),
141
+ git.run("diff --numstat -z --no-renames"),
142
+ git.run("rev-parse --verify HEAD --"),
143
+ git.run("symbolic-ref --short HEAD")
144
+ ]);
145
+ let branch = symRes.exit === 0 ? cleanBranchName(symRes.output) : "";
146
+ if (!branch) {
147
+ const current = await git.run("branch --show-current");
148
+ if (current.exit === 0)
149
+ branch = cleanBranchName(current.output);
150
+ }
151
+ return {
152
+ files: buildFallbackFiles(statusRes.output, cachedRes.output, worktreeRes.output),
153
+ branch,
154
+ hasHead: headRes.exit === 0
155
+ };
73
156
  }
74
157
  // src/git/runner.ts
75
158
  function shellQuote(s) {
@@ -261,8 +344,91 @@ function syntaxFiletype(file) {
261
344
  }
262
345
  return LANGUAGE_BY_EXTENSION[file.slice(dot).toLowerCase()];
263
346
  }
347
+ // src/ui/tree.ts
348
+ var PREFIXES = {
349
+ comfortable: { branch: "\u251C ", lastBranch: "\u2514 ", guide: "\u2502 ", emptyGuide: " ", indent: "" },
350
+ compact: { branch: "\u251C", lastBranch: "\u2514", guide: "\u2502", emptyGuide: " ", indent: "" },
351
+ minimal: { branch: "", lastBranch: "", guide: "", emptyGuide: "", indent: " " }
352
+ };
353
+ var compareNames = (a, b) => a < b ? -1 : a > b ? 1 : 0;
354
+ var baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
355
+ var newNode = () => ({ dirs: new Map, files: [] });
356
+ function buildTree(files) {
357
+ const root = newNode();
358
+ for (const f of files) {
359
+ let node = root;
360
+ for (const part of f.file.split("/").slice(0, -1)) {
361
+ let next = node.dirs.get(part);
362
+ if (!next) {
363
+ next = newNode();
364
+ node.dirs.set(part, next);
365
+ }
366
+ node = next;
367
+ }
368
+ node.files.push(f);
369
+ }
370
+ return root;
371
+ }
372
+ function buildFileTree(files, style = "comfortable", collapsed = new Set) {
373
+ const rows = [];
374
+ const p = PREFIXES[style];
375
+ const prefixOf = (depth, guides, last) => {
376
+ if (depth === 0)
377
+ return "";
378
+ if (style === "minimal")
379
+ return p.indent.repeat(depth);
380
+ return guides.map((g) => g ? p.guide : p.emptyGuide).join("") + (last ? p.lastBranch : p.branch);
381
+ };
382
+ const render = (node, depth, guides, basePath) => {
383
+ const dirNames = [...node.dirs.keys()].sort(compareNames);
384
+ const sortedFiles = [...node.files].sort((a, b) => compareNames(baseName(a.file), baseName(b.file)));
385
+ const total = dirNames.length + sortedFiles.length;
386
+ dirNames.forEach((name, i) => {
387
+ const last = i === total - 1;
388
+ let label = `${name}/`;
389
+ let fullPath = `${basePath}${name}/`;
390
+ let child = node.dirs.get(name);
391
+ while (child && child.dirs.size === 1 && child.files.length === 0) {
392
+ const [[nextName, next]] = child.dirs;
393
+ label += `${nextName}/`;
394
+ fullPath += `${nextName}/`;
395
+ child = next;
396
+ }
397
+ rows.push({ kind: "dir", prefix: prefixOf(depth, guides, last), name: label, path: fullPath });
398
+ if (child && !collapsed.has(fullPath))
399
+ render(child, depth + 1, [...guides, !last], fullPath);
400
+ });
401
+ sortedFiles.forEach((f, j) => {
402
+ const last = dirNames.length + j === total - 1;
403
+ rows.push({ kind: "file", prefix: prefixOf(depth, guides, last), name: baseName(f.file), file: f });
404
+ });
405
+ };
406
+ render(buildTree(files), 0, [], "");
407
+ return rows;
408
+ }
264
409
  // src/diff/open-diff.ts
265
- function createDiffOpener(ctx, location) {
410
+ async function gitFallbackPatch(git, file) {
411
+ const q = shellQuote(file);
412
+ return git.enqueue(async () => {
413
+ const cached = await git.run(`diff --cached --no-renames -U3 -- ${q}`);
414
+ const worktree = await git.run(`diff --no-renames -U3 -- ${q}`);
415
+ const combined = [cached.output, worktree.output].filter((s) => s.trim() !== "").join(`
416
+ `);
417
+ if (combined.trim() !== "")
418
+ return combined;
419
+ const untracked = await git.run(`diff --no-index --no-renames -U3 -- /dev/null ${q}`);
420
+ return untracked.output.trim() !== "" ? untracked.output : undefined;
421
+ });
422
+ }
423
+ async function vcsPatch(ctx, location, file) {
424
+ try {
425
+ const res = await ctx.client.vcs.diff({ location, mode: "working", context: 3 });
426
+ return res.data.find((d) => d.file === file)?.patch;
427
+ } catch {
428
+ return;
429
+ }
430
+ }
431
+ function createDiffOpener(ctx, location, git) {
266
432
  const [diffView, setDiffState] = useDiffStore(ctx);
267
433
  let activeRequestId = 0;
268
434
  return async (f) => {
@@ -283,15 +449,21 @@ function createDiffOpener(ctx, location) {
283
449
  });
284
450
  ctx.ui.panel.open(DIFF_PANEL_NAME, { presentation: "panel" });
285
451
  try {
286
- const res = await ctx.client.vcs.diff({ location: location(), mode: "working", context: 3 });
452
+ let patch = await vcsPatch(ctx, location(), f.file);
287
453
  if (isStale())
288
454
  return;
289
- const entry = res.data.find((d) => d.file === f.file);
290
- const parsed = entry ? parseDiff(entry.patch) : [];
455
+ if (patch == null) {
456
+ patch = await gitFallbackPatch(git, f.file).catch(() => {
457
+ return;
458
+ });
459
+ if (isStale())
460
+ return;
461
+ }
462
+ const parsed = patch ? parseDiff(patch) : [];
291
463
  setDiffState((draft) => {
292
464
  draft.loading = false;
293
- draft.patch = entry?.patch ?? "";
294
- if (!entry) {
465
+ draft.patch = patch ?? "";
466
+ if (!patch) {
295
467
  draft.message = "No diff available";
296
468
  } else if (parsed.length === 0) {
297
469
  draft.message = "No visible changes (empty or binary file)";
@@ -929,73 +1101,119 @@ import { insert as _$insert7 } from "@opentui/solid";
929
1101
  import { use as _$use2 } from "@opentui/solid";
930
1102
  import { setProp as _$setProp7 } from "@opentui/solid";
931
1103
  import { createElement as _$createElement7 } from "@opentui/solid";
932
- import { memo as _$memo5 } from "@opentui/solid";
1104
+ import { memo as _$memo6 } from "@opentui/solid";
933
1105
  import { createComponent as _$createComponent7 } from "@opentui/solid";
934
- import { createEffect as createEffect3, createSignal as createSignal3, onCleanup as onCleanup4, Show as Show6 } from "solid-js";
1106
+ import { createEffect as createEffect3, createSignal as createSignal3, onCleanup as onCleanup4, Show as Show7 } from "solid-js";
935
1107
 
936
1108
  // src/sidebar/file-list.tsx
1109
+ import { memo as _$memo3 } from "@opentui/solid";
1110
+ import { createComponent as _$createComponent4 } from "@opentui/solid";
937
1111
  import { effect as _$effect3 } from "@opentui/solid";
938
1112
  import { createTextNode as _$createTextNode3 } from "@opentui/solid";
939
1113
  import { insertNode as _$insertNode3 } from "@opentui/solid";
940
1114
  import { insert as _$insert4 } from "@opentui/solid";
941
- import { createComponent as _$createComponent4 } from "@opentui/solid";
942
1115
  import { setProp as _$setProp4 } from "@opentui/solid";
943
1116
  import { createElement as _$createElement4 } from "@opentui/solid";
944
- import { For as For2 } from "solid-js";
1117
+ import { createMemo as createMemo3, For as For2, Show as Show4 } from "solid-js";
945
1118
  function FileList(props) {
946
1119
  const ctx = props.ctx;
1120
+ const rows = createMemo3(() => props.treeStyle === "none" ? [] : buildFileTree(props.files, props.treeStyle, props.collapsed));
1121
+ const indexByFile = createMemo3(() => new Map(props.files.map((f, i) => [f.file, i])));
1122
+ const pathLabel = (f, name, prefix) => {
1123
+ const max = Math.max(8, filePathMaxWidth(props.width, {
1124
+ additions: f.additions,
1125
+ deletions: f.deletions
1126
+ }) - displayWidth(prefix));
1127
+ return prefix + truncatePath(name, max);
1128
+ };
1129
+ const fileRow = (f, index, name, prefix) => {
1130
+ const b = statusBadge(f.status, ctx.theme);
1131
+ const label = () => props.staged.has(f.file) ? `[${b.label}]` : ` ${b.label} `;
1132
+ return (() => {
1133
+ var _el$ = _$createElement4("box"), _el$2 = _$createElement4("text"), _el$3 = _$createElement4("b"), _el$4 = _$createElement4("text"), _el$5 = _$createElement4("box"), _el$6 = _$createElement4("text"), _el$7 = _$createTextNode3(`+`), _el$8 = _$createElement4("text"), _el$9 = _$createTextNode3(`-`);
1134
+ _$insertNode3(_el$, _el$2);
1135
+ _$insertNode3(_el$, _el$4);
1136
+ _$insertNode3(_el$, _el$5);
1137
+ _$insertNode3(_el$, _el$6);
1138
+ _$insertNode3(_el$, _el$8);
1139
+ _$setProp4(_el$, "flexDirection", "row");
1140
+ _$setProp4(_el$, "gap", 1);
1141
+ _$setProp4(_el$, "onMouseUp", () => props.onSelect(f, index));
1142
+ _$insertNode3(_el$2, _el$3);
1143
+ _$setProp4(_el$2, "width", FILE_ROW_BADGE_WIDTH);
1144
+ _$insert4(_el$3, label);
1145
+ _$setProp4(_el$4, "wrapMode", "none");
1146
+ _$insert4(_el$4, () => pathLabel(f, name, prefix));
1147
+ _$setProp4(_el$5, "flexGrow", 1);
1148
+ _$insertNode3(_el$6, _el$7);
1149
+ _$insert4(_el$6, () => f.additions, null);
1150
+ _$insertNode3(_el$8, _el$9);
1151
+ _$insert4(_el$8, () => f.deletions, null);
1152
+ _$effect3((_p$) => {
1153
+ var _v$ = b.color, _v$2 = props.navMode && index === props.selected ? ctx.theme.text.base : ctx.theme.text.muted, _v$3 = ctx.theme.diff.text.added, _v$4 = ctx.theme.diff.text.removed;
1154
+ _v$ !== _p$.e && (_p$.e = _$setProp4(_el$2, "fg", _v$, _p$.e));
1155
+ _v$2 !== _p$.t && (_p$.t = _$setProp4(_el$4, "fg", _v$2, _p$.t));
1156
+ _v$3 !== _p$.a && (_p$.a = _$setProp4(_el$6, "fg", _v$3, _p$.a));
1157
+ _v$4 !== _p$.o && (_p$.o = _$setProp4(_el$8, "fg", _v$4, _p$.o));
1158
+ return _p$;
1159
+ }, {
1160
+ e: undefined,
1161
+ t: undefined,
1162
+ a: undefined,
1163
+ o: undefined
1164
+ });
1165
+ return _el$;
1166
+ })();
1167
+ };
947
1168
  return (() => {
948
- var _el$ = _$createElement4("box");
949
- _$setProp4(_el$, "flexDirection", "column");
950
- _$setProp4(_el$, "gap", 0);
951
- _$insert4(_el$, _$createComponent4(For2, {
952
- get each() {
953
- return props.files;
1169
+ var _el$0 = _$createElement4("box");
1170
+ _$setProp4(_el$0, "flexDirection", "column");
1171
+ _$setProp4(_el$0, "gap", 0);
1172
+ _$insert4(_el$0, _$createComponent4(Show4, {
1173
+ get when() {
1174
+ return props.treeStyle !== "none";
954
1175
  },
955
- children: (f, i) => {
956
- const b = statusBadge(f.status, ctx.theme);
957
- const label = () => props.staged.has(f.file) ? `[${b.label}]` : ` ${b.label} `;
958
- return (() => {
959
- var _el$2 = _$createElement4("box"), _el$3 = _$createElement4("text"), _el$4 = _$createElement4("b"), _el$5 = _$createElement4("text"), _el$6 = _$createElement4("box"), _el$7 = _$createElement4("text"), _el$8 = _$createTextNode3(`+`), _el$9 = _$createElement4("text"), _el$0 = _$createTextNode3(`-`);
960
- _$insertNode3(_el$2, _el$3);
961
- _$insertNode3(_el$2, _el$5);
962
- _$insertNode3(_el$2, _el$6);
963
- _$insertNode3(_el$2, _el$7);
964
- _$insertNode3(_el$2, _el$9);
965
- _$setProp4(_el$2, "flexDirection", "row");
966
- _$setProp4(_el$2, "gap", 1);
967
- _$setProp4(_el$2, "onMouseUp", () => props.onSelect(f, i()));
968
- _$insertNode3(_el$3, _el$4);
969
- _$setProp4(_el$3, "width", FILE_ROW_BADGE_WIDTH);
970
- _$insert4(_el$4, label);
971
- _$setProp4(_el$5, "wrapMode", "none");
972
- _$insert4(_el$5, () => truncatePath(f.file, filePathMaxWidth(props.width, {
973
- additions: f.additions,
974
- deletions: f.deletions
975
- })));
976
- _$setProp4(_el$6, "flexGrow", 1);
977
- _$insertNode3(_el$7, _el$8);
978
- _$insert4(_el$7, () => f.additions, null);
979
- _$insertNode3(_el$9, _el$0);
980
- _$insert4(_el$9, () => f.deletions, null);
981
- _$effect3((_p$) => {
982
- var _v$ = b.color, _v$2 = props.navMode && i() === props.selected ? ctx.theme.text.base : ctx.theme.text.muted, _v$3 = ctx.theme.diff.text.added, _v$4 = ctx.theme.diff.text.removed;
983
- _v$ !== _p$.e && (_p$.e = _$setProp4(_el$3, "fg", _v$, _p$.e));
984
- _v$2 !== _p$.t && (_p$.t = _$setProp4(_el$5, "fg", _v$2, _p$.t));
985
- _v$3 !== _p$.a && (_p$.a = _$setProp4(_el$7, "fg", _v$3, _p$.a));
986
- _v$4 !== _p$.o && (_p$.o = _$setProp4(_el$9, "fg", _v$4, _p$.o));
987
- return _p$;
988
- }, {
989
- e: undefined,
990
- t: undefined,
991
- a: undefined,
992
- o: undefined
993
- });
994
- return _el$2;
995
- })();
1176
+ get fallback() {
1177
+ return _$createComponent4(For2, {
1178
+ get each() {
1179
+ return props.files;
1180
+ },
1181
+ children: (f, i) => fileRow(f, i(), f.file, "")
1182
+ });
1183
+ },
1184
+ get children() {
1185
+ return _$createComponent4(For2, {
1186
+ get each() {
1187
+ return rows();
1188
+ },
1189
+ children: (row) => row.kind === "dir" ? (() => {
1190
+ var _el$1 = _$createElement4("box"), _el$10 = _$createElement4("text"), _el$12 = _$createElement4("text");
1191
+ _$insertNode3(_el$1, _el$10);
1192
+ _$insertNode3(_el$1, _el$12);
1193
+ _$setProp4(_el$1, "flexDirection", "row");
1194
+ _$setProp4(_el$1, "gap", 1);
1195
+ _$setProp4(_el$1, "onMouseUp", () => props.onSelectDir(row.path));
1196
+ _$insertNode3(_el$10, _$createTextNode3(` `));
1197
+ _$setProp4(_el$10, "width", FILE_ROW_BADGE_WIDTH);
1198
+ _$setProp4(_el$12, "wrapMode", "none");
1199
+ _$insert4(_el$12, () => row.prefix, null);
1200
+ _$insert4(_el$12, () => props.collapsed.has(row.path) ? "\u25B8 " : "\u25BE ", null);
1201
+ _$insert4(_el$12, () => row.name, null);
1202
+ _$effect3((_p$) => {
1203
+ var _v$5 = ctx.theme.text.muted, _v$6 = ctx.theme.text.muted;
1204
+ _v$5 !== _p$.e && (_p$.e = _$setProp4(_el$10, "fg", _v$5, _p$.e));
1205
+ _v$6 !== _p$.t && (_p$.t = _$setProp4(_el$12, "fg", _v$6, _p$.t));
1206
+ return _p$;
1207
+ }, {
1208
+ e: undefined,
1209
+ t: undefined
1210
+ });
1211
+ return _el$1;
1212
+ })() : fileRow(row.file, indexByFile().get(row.file.file) ?? -1, row.name, row.prefix)
1213
+ });
996
1214
  }
997
1215
  }));
998
- return _el$;
1216
+ return _el$0;
999
1217
  })();
1000
1218
  }
1001
1219
 
@@ -1039,12 +1257,12 @@ function bindSidebarFocus(ctx, opts) {
1039
1257
  import { createComponent as _$createComponent5 } from "@opentui/solid";
1040
1258
  import { effect as _$effect4 } from "@opentui/solid";
1041
1259
  import { insert as _$insert5 } from "@opentui/solid";
1042
- import { memo as _$memo3 } from "@opentui/solid";
1260
+ import { memo as _$memo4 } from "@opentui/solid";
1043
1261
  import { createTextNode as _$createTextNode4 } from "@opentui/solid";
1044
1262
  import { insertNode as _$insertNode4 } from "@opentui/solid";
1045
1263
  import { setProp as _$setProp5 } from "@opentui/solid";
1046
1264
  import { createElement as _$createElement5 } from "@opentui/solid";
1047
- import { Show as Show4 } from "solid-js";
1265
+ import { Show as Show5 } from "solid-js";
1048
1266
  function SidebarHeader(props) {
1049
1267
  const ctx = props.ctx;
1050
1268
  return (() => {
@@ -1059,9 +1277,9 @@ function SidebarHeader(props) {
1059
1277
  _$insertNode4(_el$3, _el$4);
1060
1278
  _$insertNode4(_el$4, _$createTextNode4(`Git Panel`));
1061
1279
  _$setProp5(_el$6, "flexGrow", 1);
1062
- _$insert5(_el$2, _$createComponent5(Show4, {
1280
+ _$insert5(_el$2, _$createComponent5(Show5, {
1063
1281
  get when() {
1064
- return _$memo3(() => !!props.branch)() && props.fileCount > 0;
1282
+ return _$memo4(() => !!props.branch)() && props.fileCount > 0;
1065
1283
  },
1066
1284
  get children() {
1067
1285
  return [(() => {
@@ -1082,9 +1300,9 @@ function SidebarHeader(props) {
1082
1300
  })()];
1083
1301
  }
1084
1302
  }), null);
1085
- _$insert5(_el$2, _$createComponent5(Show4, {
1303
+ _$insert5(_el$2, _$createComponent5(Show5, {
1086
1304
  get when() {
1087
- return _$memo3(() => !!props.ready)() && props.fileCount > 0;
1305
+ return _$memo4(() => !!props.ready)() && props.fileCount > 0;
1088
1306
  },
1089
1307
  get children() {
1090
1308
  return [(() => {
@@ -1102,9 +1320,9 @@ function SidebarHeader(props) {
1102
1320
  })()];
1103
1321
  }
1104
1322
  }), null);
1105
- _$insert5(_el$, _$createComponent5(Show4, {
1323
+ _$insert5(_el$, _$createComponent5(Show5, {
1106
1324
  get when() {
1107
- return _$memo3(() => !!(props.ready && props.fileCount > 0))() && props.navMode;
1325
+ return _$memo4(() => !!(props.ready && props.fileCount > 0))() && props.navMode;
1108
1326
  },
1109
1327
  get children() {
1110
1328
  var _el$15 = _$createElement5("box"), _el$16 = _$createElement5("box");
@@ -1112,7 +1330,7 @@ function SidebarHeader(props) {
1112
1330
  _$setProp5(_el$15, "flexDirection", "row");
1113
1331
  _$setProp5(_el$15, "gap", 1);
1114
1332
  _$setProp5(_el$16, "flexGrow", 1);
1115
- _$insert5(_el$15, _$createComponent5(Show4, {
1333
+ _$insert5(_el$15, _$createComponent5(Show5, {
1116
1334
  get when() {
1117
1335
  return props.stageMode;
1118
1336
  },
@@ -1131,7 +1349,7 @@ function SidebarHeader(props) {
1131
1349
  return _el$17;
1132
1350
  }
1133
1351
  }), null);
1134
- _$insert5(_el$15, _$createComponent5(Show4, {
1352
+ _$insert5(_el$15, _$createComponent5(Show5, {
1135
1353
  get when() {
1136
1354
  return props.hasStaged;
1137
1355
  },
@@ -1351,7 +1569,15 @@ function createListNavigation(ctx, files) {
1351
1569
  }
1352
1570
 
1353
1571
  // src/sidebar/staging.tsx
1354
- import { createMemo as createMemo3 } from "solid-js";
1572
+ import { createMemo as createMemo4 } from "solid-js";
1573
+ function shouldStageAll(files, staged) {
1574
+ return !files.every((f) => staged.has(f));
1575
+ }
1576
+ var HEAD_ERROR_RE = /could not resolve HEAD|ambiguous argument ['"]?HEAD|invalid object name ['"]?HEAD/i;
1577
+ function buildStageArgs(stage, files) {
1578
+ const paths = files.map(shellQuote).join(" ");
1579
+ return stage ? `add -- ${paths}` : `restore --staged -- ${paths}`;
1580
+ }
1355
1581
  function createStaging(ctx, git, location, refreshWorkingCopy) {
1356
1582
  const [stagedMem, setStagedMem] = ctx.storage.memory(STAGED_STORE_KEY, {
1357
1583
  initial: {
@@ -1359,7 +1585,7 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1359
1585
  files: []
1360
1586
  }
1361
1587
  });
1362
- const staged = createMemo3(() => new Set(stagedMem.files));
1588
+ const staged = createMemo4(() => new Set(stagedMem.files));
1363
1589
  if (stagedMem.directory !== location().directory) {
1364
1590
  setStagedMem((draft) => {
1365
1591
  draft.directory = location().directory;
@@ -1370,12 +1596,12 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1370
1596
  const fetchStatuses = async () => {
1371
1597
  try {
1372
1598
  const res = await git.run("status --porcelain=v1 -z -uall --no-renames");
1373
- if (res.exit === 0) {
1374
- const files = [...parseStagedPorcelain(res.output)];
1375
- setStagedMem((draft) => {
1376
- draft.files = files;
1377
- });
1378
- }
1599
+ if (res.exit !== 0)
1600
+ return;
1601
+ const files = [...parseStagedPorcelain(res.output)];
1602
+ setStagedMem((draft) => {
1603
+ draft.files = files;
1604
+ });
1379
1605
  } catch {}
1380
1606
  };
1381
1607
  const scheduleRefresh = async () => {
@@ -1386,43 +1612,54 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1386
1612
  });
1387
1613
  return refreshTask;
1388
1614
  };
1389
- const applyChange = (file, stage) => {
1615
+ const toastError = (message) => ctx.ui.toast.show({
1616
+ message,
1617
+ variant: "error"
1618
+ });
1619
+ const tail = (output) => output.trim().slice(0, 200);
1620
+ const stageFiles = async (stage, files) => {
1621
+ const res = await git.run(buildStageArgs(stage, files));
1622
+ if (res.exit === 0)
1623
+ return;
1624
+ if (!stage && HEAD_ERROR_RE.test(res.output)) {
1625
+ for (const file of files) {
1626
+ const q = shellQuote(file);
1627
+ let r = await git.run(`restore --staged -- ${q}`);
1628
+ if (r.exit !== 0 && HEAD_ERROR_RE.test(r.output))
1629
+ r = await git.run(`rm --cached -- ${q}`);
1630
+ if (r.exit !== 0)
1631
+ toastError(`git restore failed: ${tail(r.output)}`);
1632
+ }
1633
+ return;
1634
+ }
1635
+ toastError(`git ${stage ? "add" : "restore"} failed: ${tail(res.output)}`);
1636
+ };
1637
+ const applyFiles = (files, stage) => {
1638
+ if (files.length === 0)
1639
+ return;
1390
1640
  setStagedMem((draft) => {
1391
1641
  const next = new Set(draft.files);
1392
- if (stage)
1393
- next.add(file);
1394
- else
1395
- next.delete(file);
1642
+ for (const file of files) {
1643
+ if (stage)
1644
+ next.add(file);
1645
+ else
1646
+ next.delete(file);
1647
+ }
1396
1648
  draft.files = [...next];
1397
1649
  });
1398
1650
  git.enqueue(async () => {
1399
1651
  try {
1400
- let res = await git.run(stage ? `add -- ${shellQuote(file)}` : `restore --staged -- ${shellQuote(file)}`);
1401
- if (!stage && res.exit !== 0 && /could not resolve HEAD|ambiguous argument ['"]?HEAD|invalid object name ['"]?HEAD/i.test(res.output)) {
1402
- res = await git.run(`rm --cached -- ${shellQuote(file)}`);
1403
- }
1404
- if (res.exit !== 0) {
1405
- ctx.ui.toast.show({
1406
- message: `git ${stage ? "add" : "restore"} failed: ${res.output.trim().slice(0, 200)}`,
1407
- variant: "error"
1408
- });
1409
- await fetchStatuses();
1410
- return;
1411
- }
1412
- await fetchStatuses();
1652
+ await stageFiles(stage, files);
1413
1653
  } catch (e) {
1414
- ctx.ui.toast.show({
1415
- message: `git failed: ${friendlyError(e)}`,
1416
- variant: "error"
1417
- });
1654
+ toastError(`git failed: ${friendlyError(e)}`);
1655
+ } finally {
1418
1656
  await fetchStatuses();
1419
- return;
1420
1657
  }
1421
1658
  });
1422
1659
  };
1423
- const toggle = (file) => {
1424
- applyChange(file, !staged().has(file));
1425
- };
1660
+ const applyChange = (file, stage) => applyFiles([file], stage);
1661
+ const toggle = (file) => applyChange(file, !staged().has(file));
1662
+ const toggleAll = (files) => applyFiles(files, shouldStageAll(files, staged()));
1426
1663
  const commit = async () => {
1427
1664
  await scheduleRefresh();
1428
1665
  if (staged().size === 0) {
@@ -1442,10 +1679,7 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1442
1679
  try {
1443
1680
  const res = await git.run(`commit -m ${shellQuote(message)}`);
1444
1681
  if (res.exit !== 0) {
1445
- ctx.ui.toast.show({
1446
- message: `git commit failed: ${res.output.trim().slice(0, 200)}`,
1447
- variant: "error"
1448
- });
1682
+ toastError(`git commit failed: ${tail(res.output)}`);
1449
1683
  } else {
1450
1684
  ctx.ui.toast.show({
1451
1685
  message: "Committed",
@@ -1453,10 +1687,7 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1453
1687
  });
1454
1688
  }
1455
1689
  } catch (e) {
1456
- ctx.ui.toast.show({
1457
- message: `git commit failed: ${friendlyError(e)}`,
1458
- variant: "error"
1459
- });
1690
+ toastError(`git commit failed: ${friendlyError(e)}`);
1460
1691
  }
1461
1692
  await refreshWorkingCopy();
1462
1693
  });
@@ -1466,23 +1697,24 @@ function createStaging(ctx, git, location, refreshWorkingCopy) {
1466
1697
  scheduleRefresh,
1467
1698
  applyChange,
1468
1699
  toggle,
1700
+ toggleAll,
1469
1701
  commit
1470
1702
  };
1471
1703
  }
1472
1704
 
1473
1705
  // src/sidebar/status.tsx
1474
1706
  import { insert as _$insert6 } from "@opentui/solid";
1475
- import { memo as _$memo4 } from "@opentui/solid";
1707
+ import { memo as _$memo5 } from "@opentui/solid";
1476
1708
  import { createComponent as _$createComponent6 } from "@opentui/solid";
1477
1709
  import { setProp as _$setProp6 } from "@opentui/solid";
1478
1710
  import { effect as _$effect5 } from "@opentui/solid";
1479
1711
  import { createTextNode as _$createTextNode5 } from "@opentui/solid";
1480
1712
  import { insertNode as _$insertNode5 } from "@opentui/solid";
1481
1713
  import { createElement as _$createElement6 } from "@opentui/solid";
1482
- import { Show as Show5 } from "solid-js";
1714
+ import { Show as Show6 } from "solid-js";
1483
1715
  function SidebarStatus(props) {
1484
1716
  const ctx = props.ctx;
1485
- return [_$createComponent6(Show5, {
1717
+ return [_$createComponent6(Show6, {
1486
1718
  get when() {
1487
1719
  return props.loading;
1488
1720
  },
@@ -1492,9 +1724,9 @@ function SidebarStatus(props) {
1492
1724
  _$effect5((_$p) => _$setProp6(_el$, "fg", ctx.theme.text.muted, _$p));
1493
1725
  return _el$;
1494
1726
  }
1495
- }), _$createComponent6(Show5, {
1727
+ }), _$createComponent6(Show6, {
1496
1728
  get when() {
1497
- return _$memo4(() => !!!props.loading)() && props.error;
1729
+ return _$memo5(() => !!!props.loading)() && props.error;
1498
1730
  },
1499
1731
  get children() {
1500
1732
  var _el$3 = _$createElement6("text");
@@ -1502,9 +1734,9 @@ function SidebarStatus(props) {
1502
1734
  _$effect5((_$p) => _$setProp6(_el$3, "fg", ctx.theme.text.feedback.error.base, _$p));
1503
1735
  return _el$3;
1504
1736
  }
1505
- }), _$createComponent6(Show5, {
1737
+ }), _$createComponent6(Show6, {
1506
1738
  get when() {
1507
- return _$memo4(() => !!(!props.loading && !props.error && !props.hasFiles))() && !props.branch;
1739
+ return _$memo5(() => !!(!props.loading && !props.error && !props.hasFiles))() && !props.hasHead;
1508
1740
  },
1509
1741
  get children() {
1510
1742
  var _el$4 = _$createElement6("text");
@@ -1512,9 +1744,9 @@ function SidebarStatus(props) {
1512
1744
  _$effect5((_$p) => _$setProp6(_el$4, "fg", ctx.theme.text.muted, _$p));
1513
1745
  return _el$4;
1514
1746
  }
1515
- }), _$createComponent6(Show5, {
1747
+ }), _$createComponent6(Show6, {
1516
1748
  get when() {
1517
- return _$memo4(() => !!(!props.loading && !props.error && !props.hasFiles))() && !!props.branch;
1749
+ return _$memo5(() => !!(!props.loading && !props.error && !props.hasFiles))() && props.hasHead;
1518
1750
  },
1519
1751
  get children() {
1520
1752
  var _el$6 = _$createElement6("text");
@@ -1526,44 +1758,69 @@ function SidebarStatus(props) {
1526
1758
  }
1527
1759
 
1528
1760
  // src/sidebar/working-copy.tsx
1529
- import { createEffect as createEffect2, createMemo as createMemo4, createSignal as createSignal2, onCleanup as onCleanup3 } from "solid-js";
1530
- function createWorkingCopy(ctx, location, onRefreshed) {
1761
+ import { createEffect as createEffect2, createMemo as createMemo5, createSignal as createSignal2, onCleanup as onCleanup3 } from "solid-js";
1762
+ var EMPTY_VCS = {
1763
+ files: [],
1764
+ branch: "",
1765
+ hasHead: true
1766
+ };
1767
+ function createWorkingCopy(ctx, location, git, onRefreshed) {
1531
1768
  const [files, setFiles] = createSignal2([]);
1532
1769
  const [branch, setBranch] = createSignal2("");
1770
+ const [hasHead, setHasHead] = createSignal2(true);
1533
1771
  const [loading, setLoading] = createSignal2(true);
1534
1772
  const [error, setError] = createSignal2(undefined);
1535
1773
  let isRefreshing = false;
1536
1774
  let lastEventRefreshAt = 0;
1537
- const refresh = async () => {
1538
- if (isRefreshing)
1539
- return;
1540
- isRefreshing = true;
1775
+ const applyFiles = (next) => {
1776
+ const prevByFile = new Map(files().map((f) => [f.file, f]));
1777
+ setFiles(next.map((d) => {
1778
+ const p = prevByFile.get(d.file);
1779
+ return p && p.additions === d.additions && p.deletions === d.deletions && p.status === d.status ? p : {
1780
+ ...d
1781
+ };
1782
+ }));
1783
+ };
1784
+ const loadFromVcs = async (loc) => {
1541
1785
  try {
1542
- const loc = location();
1543
1786
  const [statusRes, infoRes] = await Promise.all([ctx.client.vcs.status({
1544
1787
  location: loc
1545
1788
  }), ctx.client.vcs.get({
1546
1789
  location: loc
1547
1790
  })]);
1548
- const prevByFile = new Map(files().map((f) => [f.file, f]));
1549
- setFiles(statusRes.data.map((d) => {
1550
- const p = prevByFile.get(d.file);
1551
- return p && p.additions === d.additions && p.deletions === d.deletions && p.status === d.status ? p : {
1552
- ...d
1553
- };
1554
- }));
1555
- setBranch(infoRes.data.branch.current ?? "");
1791
+ return {
1792
+ files: statusRes.data,
1793
+ branch: infoRes.data.branch.current ?? "",
1794
+ hasHead: true
1795
+ };
1796
+ } catch {
1797
+ return EMPTY_VCS;
1798
+ }
1799
+ };
1800
+ const isEmpty = (s) => s.files.length === 0 && s.branch === "";
1801
+ const refresh = async () => {
1802
+ if (isRefreshing)
1803
+ return;
1804
+ isRefreshing = true;
1805
+ try {
1806
+ const vcs = await loadFromVcs(location());
1807
+ const next = isEmpty(vcs) ? await git.enqueue(() => fetchWorkingCopyFallback(git)) : vcs;
1808
+ applyFiles(next.files);
1809
+ setBranch(next.branch);
1810
+ setHasHead(next.hasHead);
1811
+ onRefreshed(next.files.length);
1556
1812
  setError(undefined);
1557
- onRefreshed(statusRes.data.length);
1558
1813
  } catch (e) {
1814
+ applyFiles([]);
1815
+ setBranch("");
1559
1816
  setError(friendlyError(e));
1560
1817
  } finally {
1561
1818
  setLoading(false);
1562
1819
  isRefreshing = false;
1563
1820
  }
1564
1821
  };
1565
- const additions = createMemo4(() => files().reduce((n, f) => n + f.additions, 0));
1566
- const deletions = createMemo4(() => files().reduce((n, f) => n + f.deletions, 0));
1822
+ const additions = createMemo5(() => files().reduce((n, f) => n + f.additions, 0));
1823
+ const deletions = createMemo5(() => files().reduce((n, f) => n + f.deletions, 0));
1567
1824
  const [refreshSignal, bumpRefresh] = ctx.storage.memory(REFRESH_STORE_KEY, {
1568
1825
  initial: {
1569
1826
  nonce: 0
@@ -1598,6 +1855,7 @@ function createWorkingCopy(ctx, location, onRefreshed) {
1598
1855
  return {
1599
1856
  files,
1600
1857
  branch,
1858
+ hasHead,
1601
1859
  loading,
1602
1860
  error,
1603
1861
  additions,
@@ -1613,6 +1871,7 @@ function GitChangesView(props) {
1613
1871
  ctx
1614
1872
  } = props;
1615
1873
  const [sidebarWidth, setSidebarWidth] = createSignal3(46);
1874
+ const [collapsedDirs, setCollapsedDirs] = createSignal3(new Set);
1616
1875
  const [diffView] = useDiffStore(ctx);
1617
1876
  let sidebarNode;
1618
1877
  const currentLocation = () => {
@@ -1622,8 +1881,8 @@ function GitChangesView(props) {
1622
1881
  };
1623
1882
  };
1624
1883
  const git = createGitRunner(ctx, () => currentLocation().directory);
1625
- const openDiff = createDiffOpener(ctx, currentLocation);
1626
- const workingCopy = createWorkingCopy(ctx, currentLocation, (count) => {
1884
+ const openDiff = createDiffOpener(ctx, currentLocation, git);
1885
+ const workingCopy = createWorkingCopy(ctx, currentLocation, git, (count) => {
1627
1886
  nav.clampSelection(count);
1628
1887
  staging.scheduleRefresh();
1629
1888
  });
@@ -1646,6 +1905,30 @@ function GitChangesView(props) {
1646
1905
  else
1647
1906
  openDiff(f).catch(() => {});
1648
1907
  };
1908
+ const toggleCollapsed = (dirPath) => setCollapsedDirs((prev) => {
1909
+ const next = new Set(prev);
1910
+ if (!next.delete(dirPath))
1911
+ next.add(dirPath);
1912
+ return next;
1913
+ });
1914
+ const selectDir = (dirPath) => {
1915
+ if (!nav.stageMode()) {
1916
+ toggleCollapsed(dirPath);
1917
+ return;
1918
+ }
1919
+ const contained = workingCopy.files().map((f, i) => ({
1920
+ f,
1921
+ i
1922
+ })).filter(({
1923
+ f
1924
+ }) => f.file.startsWith(dirPath));
1925
+ if (contained.length === 0)
1926
+ return;
1927
+ staging.toggleAll(contained.map(({
1928
+ f
1929
+ }) => f.file));
1930
+ nav.setSelected(contained[0].i);
1931
+ };
1649
1932
  workingCopy.refresh().catch(() => {});
1650
1933
  staging.scheduleRefresh().catch(() => {});
1651
1934
  registerSidebarKeymaps(ctx, {
@@ -1734,12 +2017,12 @@ function GitChangesView(props) {
1734
2017
  get hasFiles() {
1735
2018
  return hasFiles();
1736
2019
  },
1737
- get branch() {
1738
- return workingCopy.branch();
2020
+ get hasHead() {
2021
+ return workingCopy.hasHead();
1739
2022
  }
1740
- }), _$createComponent7(Show6, {
2023
+ }), _$createComponent7(Show7, {
1741
2024
  get when() {
1742
- return _$memo5(() => !!ready())() && hasFiles();
2025
+ return _$memo6(() => !!ready())() && hasFiles();
1743
2026
  },
1744
2027
  get children() {
1745
2028
  return _$createComponent7(FileList, {
@@ -1759,11 +2042,18 @@ function GitChangesView(props) {
1759
2042
  get width() {
1760
2043
  return sidebarWidth();
1761
2044
  },
1762
- onSelect: selectFile
2045
+ get treeStyle() {
2046
+ return folderTree(ctx);
2047
+ },
2048
+ get collapsed() {
2049
+ return collapsedDirs();
2050
+ },
2051
+ onSelect: selectFile,
2052
+ onSelectDir: selectDir
1763
2053
  });
1764
2054
  }
1765
2055
  })];
1766
- return _$createComponent7(Show6, {
2056
+ return _$createComponent7(Show7, {
1767
2057
  get when() {
1768
2058
  return nav.navMode();
1769
2059
  },
@@ -1822,7 +2112,12 @@ var tui_default = Plugin.define({
1822
2112
  }
1823
2113
  });
1824
2114
  export {
2115
+ porcelainChangeStatus,
1825
2116
  parseStagedPorcelain,
2117
+ parsePorcelainRecords,
2118
+ parseNumstat,
1826
2119
  parseDiff,
1827
- tui_default as default
2120
+ tui_default as default,
2121
+ buildFileTree,
2122
+ buildFallbackFiles
1828
2123
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-git-plugin",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "OpenCode v2 TUI sidebar plugin listing git working-copy changes with diff preview",
6
6
  "type": "module",
7
7
  "exports": {