my-durable 0.0.25 → 0.0.26

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.
@@ -1,22 +1,34 @@
1
1
  <script setup lang="ts">
2
2
  // Inspector GIT tab — the IDE git window, in the app's own design language
3
3
  // (panel-heading + tags + task-sections). IntelliJ features: branch popup,
4
- // changelist commit with per-file diff/stage/discard, structured log,
5
- // push with ahead count, stash, worktree sessions.
4
+ // changelist commit with per-file diff/stage/unstage/discard, structured log
5
+ // with swimlane graph, push with ahead count + recovery hints, stash with
6
+ // auto-stash on switch, conflict banner + abort, checkpoints/rewind,
7
+ // session-scoped commits, worktree sessions.
8
+ // Checklist: docs/git-ui-checklist.md (orca main, paseo/zcode niceties).
6
9
  import { computed, onServerPrefetch, ref, watch } from "vue";
7
10
  import {
8
11
  ArrowDownToLine,
12
+ ArrowRight,
9
13
  ArrowUpFromLine,
14
+ Ban,
10
15
  Check,
11
16
  ChevronDown,
17
+ ChevronRight,
18
+ Copy,
12
19
  CornerUpLeft,
13
20
  Eye,
21
+ Flag,
14
22
  GitBranch,
23
+ GitCompare,
15
24
  History,
25
+ Minus,
16
26
  PackageOpen,
17
27
  Plus,
18
28
  RefreshCw,
29
+ Sparkles,
19
30
  SquareStack,
31
+ Undo2,
20
32
  X,
21
33
  } from "lucide-vue-next";
22
34
  import { deviceId, network, sessionSummary, notify, select } from "../client/store";
@@ -33,6 +45,10 @@ const status = computed(() => entry.value.status);
33
45
  const loading = computed(() => entry.value.loading);
34
46
  const refreshing = computed(() => entry.value.refreshing);
35
47
  const error = computed(() => entry.value.error);
48
+ const sessionId = computed(() => {
49
+ const s = sessionSummary.value;
50
+ return s && s.cwd === cwd.value && !props.deviceId ? s.id : undefined;
51
+ });
36
52
 
37
53
  const busy = ref("");
38
54
  const out = ref("");
@@ -43,14 +59,62 @@ const wtMenu = ref(false);
43
59
  const newBranch = ref("");
44
60
  /** IntelliJ: click a changed file → its diff, right there. */
45
61
  const fileDiff = ref<{ path: string; text: string } | null>(null);
62
+ /** Working vs staged — paseo's diff modes. */
63
+ const diffMode = ref<"working" | "staged">("working");
64
+ /** orca commit drafts: the message survives navigation and reloads. */
65
+ const draftKey = computed(() => `dpi-git-draft:${devId.value}:${cwd.value}`);
66
+ /** Compare vs an arbitrary base (orca git-bridge). */
67
+ const base = ref<{ ref: string; ahead: number; behind: number } | null>(null);
68
+ /** paseo: stash → switch → pop, so a dirty tree never blocks a branch hop. */
69
+ const autoStash = ref(true);
70
+ /** Push/pull failures get a RECOVERY action, not just red text. */
71
+ const pushHint = ref("");
72
+ const pullHint = ref(false);
73
+ /** Session-scoped commit (zcode flagship): files this session's agent touched. */
74
+ const sessionFiles = ref<string[]>([]);
75
+ const sessionCount = computed(() => {
76
+ if (!sessionFiles.value.length || !cwd.value) return null;
77
+ const abs = (p: string) => (p.startsWith("/") || p.includes(":\\") ? p : `${cwd.value}/${p}`);
78
+ const touched = new Set(sessionFiles.value);
79
+ const mine = (status.value?.files ?? []).filter((f) => touched.has(abs(f.path)));
80
+ return { changed: mine.length, total: sessionFiles.value.length };
81
+ });
82
+ /** Checkpoints (zcode): named refs of HEAD you can rewind to. */
83
+ const checkpoints = ref<{ name: string; hash: string; date: string; subject: string }[]>([]);
84
+ const cpsOpen = ref(false);
85
+ /** Log row expander: files of one commit. */
86
+ const expanded = ref<Map<string, { status: string; path: string }[]>>(new Map());
87
+ /** Folder grouping (orca tree-directory-rows): collapse noise. */
88
+ const collapsed = ref<Set<string>>(new Set());
46
89
 
47
- if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
90
+ const draftLoad = () => {
91
+ if (typeof localStorage === "undefined") return;
92
+ commitMsg.value = localStorage.getItem(draftKey.value) ?? "";
93
+ };
94
+ if (!import.meta.env.SSR) {
95
+ void ensureGit(devId.value || "?", cwd.value || "?");
96
+ draftLoad();
97
+ }
48
98
  onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
99
+ watch(commitMsg, (m) => {
100
+ if (typeof localStorage === "undefined") return;
101
+ if (m) localStorage.setItem(draftKey.value, m);
102
+ else localStorage.removeItem(draftKey.value);
103
+ });
49
104
  watch([devId, cwd], () => {
50
105
  branchMenu.value = false;
51
106
  wtMenu.value = false;
52
107
  fileDiff.value = null;
53
- void ensureGit(devId.value || "?", cwd.value || "?");
108
+ base.value = null;
109
+ pushHint.value = "";
110
+ pullHint.value = false;
111
+ sessionFiles.value = [];
112
+ expanded.value = new Map();
113
+ collapsed.value = new Set();
114
+ if (!import.meta.env.SSR) {
115
+ void ensureGit(devId.value || "?", cwd.value || "?");
116
+ draftLoad();
117
+ }
54
118
  });
55
119
  const refresh = () => {
56
120
  checked.value = new Set();
@@ -68,7 +132,15 @@ const someChecked = computed(() => files.value.some((f) => checked.value.has(f.p
68
132
  /** orca's split: staged (index changed) vs unstaged (worktree + untracked). */
69
133
  const stagedFiles = computed(() => files.value.filter((f) => f.x !== " " && f.x !== "?"));
70
134
  const unstagedFiles = computed(() => files.value.filter((f) => f.x === " " || f.x === "?"));
135
+ const conflictFiles = computed(() => files.value.filter((f) => /U|A|D/.test(f.x) && /U|A|D/.test(f.y)));
71
136
  const atCap = computed(() => files.value.length >= 500);
137
+ const dirty = computed(() => (status.value?.staged ?? 0) + (status.value?.unstaged ?? 0) + (status.value?.untracked ?? 0) > 0);
138
+ const noIdentity = computed(() => !status.value?.identity?.name || !status.value?.identity?.email);
139
+ const remoteWeb = computed(() => {
140
+ const u = status.value?.remoteUrl ?? "";
141
+ const m = u.match(/git@([^:]+):(.+?)(?:\.git)?$/) ?? u.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
142
+ return m ? `https://${m[1]}/${m[2]}` : "";
143
+ });
72
144
  const toggle = (p: string) => {
73
145
  const next = new Set(checked.value);
74
146
  if (next.has(p)) next.delete(p);
@@ -87,19 +159,63 @@ const toggleGroup = (group: { path: string }[]) => {
87
159
  }
88
160
  checked.value = next;
89
161
  };
162
+ const dirOf = (p: string) => {
163
+ const i = p.lastIndexOf("/");
164
+ return i === -1 ? "." : p.slice(0, i);
165
+ };
166
+ const grouped = (list: { path: string; x: string; y: string; a?: number; d?: number }[]) => {
167
+ const byDir = new Map<string, typeof list>();
168
+ for (const f of list) {
169
+ const d = dirOf(f.path);
170
+ if (!byDir.has(d)) byDir.set(d, []);
171
+ byDir.get(d)!.push(f);
172
+ }
173
+ return [...byDir.entries()].map(([dir, fs]) => ({
174
+ dir,
175
+ files: fs,
176
+ open: !collapsed.value.has(dir),
177
+ }));
178
+ };
179
+ const collapse = (dir: string) => {
180
+ const next = new Set(collapsed.value);
181
+ if (next.has(dir)) next.delete(dir);
182
+ else next.add(dir);
183
+ collapsed.value = next;
184
+ };
185
+ const numChip = (f: { a?: number; d?: number }) =>
186
+ f.a === undefined && f.d === undefined ? "" : f.a === -1 || f.d === -1 ? "bin" : `+${f.a ?? 0} −${f.d ?? 0}`;
187
+ const copyPath = (p: string) => {
188
+ void navigator.clipboard?.writeText(p).then(() => notify(`copied ${p}`));
189
+ };
90
190
 
91
- async function op(name: "fetch" | "pull" | "push" | "branch" | "diff" | "stash-push" | "stash-pop", extra: { message?: string } = {}) {
191
+ async function op(
192
+ name: "fetch" | "pull" | "push" | "branch" | "diff" | "stash-push" | "stash-pop",
193
+ extra: { message?: string; rebase?: boolean; setUpstream?: boolean; ref?: string } = {},
194
+ ) {
92
195
  if (!devId.value || !cwd.value) return;
93
196
  busy.value = name;
94
197
  out.value = "";
95
198
  try {
96
199
  const r = await deviceClient(devId.value).gitOp(cwd.value, name, extra);
97
200
  out.value = r.text || "(empty)";
201
+ if (name === "push") {
202
+ pushHint.value = "";
203
+ }
204
+ if (name === "pull") pullHint.value = false;
98
205
  if (["fetch", "pull", "push", "stash-push", "stash-pop"].includes(name)) notify(`git ${name} ✓`);
99
206
  await refresh();
100
207
  void loadLog(true);
101
208
  } catch (e) {
102
- notify(`git ${name}: ${(e as Error).message.slice(0, 160)}`);
209
+ const msg = (e as Error).message;
210
+ if (name === "push") {
211
+ // orca push-recovery: name the fix, offer the button.
212
+ if (/no upstream|has no upstream|upstream is gone/i.test(msg)) pushHint.value = "no upstream — set it while pushing?";
213
+ else if (/fetch first|non-fast-forward|rejected.*fetch|behind/i.test(msg)) pushHint.value = "the remote has work you lack — pull first";
214
+ else if (/authentication|permission|could not read|403|denied/i.test(msg)) pushHint.value = "credentials rejected on the device — check its git login";
215
+ else pushHint.value = "";
216
+ }
217
+ if (name === "pull" && /not possible to fast-forward|divergent|unrelated histories/i.test(msg)) pullHint.value = true;
218
+ notify(`git ${name}: ${msg.slice(0, 160)}`);
103
219
  } finally {
104
220
  busy.value = "";
105
221
  }
@@ -122,12 +238,40 @@ async function fileAction(name: "stage" | "discard", path?: string) {
122
238
  }
123
239
  }
124
240
 
241
+ async function unstageFiles(paths: string[]) {
242
+ if (!devId.value || !cwd.value || !paths.length) return;
243
+ busy.value = "unstage";
244
+ try {
245
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "unstage", { files: paths });
246
+ notify(r.text);
247
+ await refresh();
248
+ } catch (e) {
249
+ notify(`unstage failed: ${(e as Error).message.slice(0, 140)}`);
250
+ } finally {
251
+ busy.value = "";
252
+ }
253
+ }
254
+
255
+ async function ignorePaths(paths: string[]) {
256
+ if (!devId.value || !cwd.value || !paths.length) return;
257
+ busy.value = "ignore";
258
+ try {
259
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "ignore", { lines: paths });
260
+ notify(r.text);
261
+ await refresh();
262
+ } catch (e) {
263
+ notify(`ignore failed: ${(e as Error).message.slice(0, 140)}`);
264
+ } finally {
265
+ busy.value = "";
266
+ }
267
+ }
268
+
125
269
  async function showDiff(path: string) {
126
270
  if (!devId.value || !cwd.value) return;
127
271
  if (fileDiff.value?.path === path) return (fileDiff.value = null);
128
272
  busy.value = "diff";
129
273
  try {
130
- const r = await deviceClient(devId.value).gitOp(cwd.value, "diff-file", { target: path });
274
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "diff-file", { target: path, staged: diffMode.value === "staged" });
131
275
  fileDiff.value = { path, text: r.text };
132
276
  } catch (e) {
133
277
  notify(`diff failed: ${(e as Error).message.slice(0, 140)}`);
@@ -135,35 +279,98 @@ async function showDiff(path: string) {
135
279
  busy.value = "";
136
280
  }
137
281
  }
282
+ const reDiff = async () => {
283
+ const p = fileDiff.value?.path;
284
+ fileDiff.value = null;
285
+ if (p) await showDiff(p);
286
+ };
287
+ const diffIsBinary = computed(() => /Binary files .* differ/i.test(fileDiff.value?.text ?? ""));
288
+
289
+ async function gitAbort() {
290
+ if (!devId.value || !cwd.value) return;
291
+ busy.value = "abort";
292
+ try {
293
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "merge-abort");
294
+ notify(r.text);
295
+ await refresh();
296
+ } catch (e) {
297
+ notify(`abort failed: ${(e as Error).message.slice(0, 120)}`);
298
+ } finally {
299
+ busy.value = "";
300
+ }
301
+ }
302
+
303
+ async function compareWith(ref: string) {
304
+ branchMenu.value = false;
305
+ if (!devId.value || !cwd.value) return;
306
+ busy.value = "compare";
307
+ try {
308
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "compare", { ref });
309
+ base.value = { ref, ahead: r.ahead ?? 0, behind: r.behind ?? 0 };
310
+ } catch (e) {
311
+ notify(`compare failed: ${(e as Error).message.slice(0, 140)}`);
312
+ } finally {
313
+ busy.value = "";
314
+ }
315
+ }
138
316
 
139
317
  async function switchBranch(name: string, create = false) {
140
318
  branchMenu.value = false;
141
319
  if (!devId.value || !cwd.value || (name === status.value?.branch && !create)) return;
142
320
  busy.value = "switch";
321
+ const client = deviceClient(devId.value);
322
+ let stashed = false;
143
323
  try {
144
- const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name, create });
324
+ // paseo auto-stash: a dirty tree never blocks a branch hop.
325
+ if (autoStash.value && !create && dirty.value) {
326
+ await client.gitOp(cwd.value, "stash-push", { message: `dpi auto-stash before ${name}` });
327
+ stashed = true;
328
+ }
329
+ const r = await client.gitOp(cwd.value, "switch", { ref: name, create });
145
330
  notify(r.text || `switched to ${name}`);
331
+ if (stashed) {
332
+ try {
333
+ const pop = await client.gitOp(cwd.value, "stash-pop");
334
+ if (/conflict/i.test(pop.text)) notify(`stash popped WITH CONFLICTS — resolve in the file list`);
335
+ } catch {
336
+ notify(`switched, but the stash did not pop — resolve manually (git stash list)`);
337
+ }
338
+ }
146
339
  await refresh();
147
340
  void loadLog(true);
148
341
  } catch (e) {
149
342
  notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
343
+ if (stashed) {
344
+ try {
345
+ await client.gitOp(cwd.value, "stash-pop");
346
+ } catch {}
347
+ }
150
348
  } finally {
151
349
  busy.value = "";
152
350
  }
153
351
  }
154
352
 
155
- async function commit() {
353
+ async function commit(thenPush = false) {
156
354
  if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
157
355
  busy.value = "commit";
158
356
  const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
159
357
  if (!chosen.length) return notify("check the files to commit — nothing is selected");
358
+ const client = deviceClient(devId.value);
160
359
  try {
161
- const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
360
+ const r = await client.gitOp(cwd.value, "commit", {
162
361
  message: commitMsg.value.trim(),
163
362
  files: chosen,
164
363
  });
165
364
  notify(r.text);
166
365
  commitMsg.value = "";
366
+ if (thenPush) {
367
+ try {
368
+ const p = await client.gitOp(cwd.value, "push");
369
+ notify(`pushed: ${p.text.slice(0, 80)}`);
370
+ } catch (e) {
371
+ notify(`committed, but push failed: ${(e as Error).message.slice(0, 120)}`);
372
+ }
373
+ }
167
374
  await refresh();
168
375
  void loadLog(true);
169
376
  } catch (e) {
@@ -173,6 +380,123 @@ async function commit() {
173
380
  }
174
381
  }
175
382
 
383
+ /** orca/zcode AI message: ask the device's own model to title the changelist. */
384
+ async function aiMessage() {
385
+ if (!devId.value || !cwd.value) return;
386
+ const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
387
+ if (!chosen.length) return notify("check files first — the model summarises THOSE");
388
+ busy.value = "ai";
389
+ const client = deviceClient(devId.value);
390
+ let sid = "";
391
+ try {
392
+ const info = network.devices.value.find((d) => d.id === devId.value)?.info;
393
+ const model = (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
394
+ if (!model) return notify("no model on the device");
395
+ let diff = "";
396
+ for (const f of chosen.slice(0, 5)) {
397
+ const d = await client.gitOp(cwd.value, "diff-file", { target: f });
398
+ diff += `${d.text}\n`;
399
+ if (diff.length > 4000) break;
400
+ }
401
+ diff = diff.slice(0, 4000);
402
+ const s = await client.createSession({ name: "git-msg", cwd: cwd.value, model: { provider: model.provider, modelId: model.id } });
403
+ sid = s.id;
404
+ const r = await client.agent(sid, 1).send(
405
+ `Write ONE concise git commit message (under 72 chars, imperative, no quotes, no backticks) for these changes:\n\n${diff}`,
406
+ "followUp",
407
+ undefined,
408
+ 120_000,
409
+ );
410
+ const text = r.status === "done" ? r.answer.text.trim().split("\n")[0]?.slice(0, 100) : "";
411
+ if (text) commitMsg.value = text;
412
+ else notify("the model returned nothing usable");
413
+ } catch (e) {
414
+ notify(`ai message failed: ${(e as Error).message.slice(0, 120)}`);
415
+ } finally {
416
+ busy.value = "";
417
+ if (sid) void client.session(sid).archive().catch(() => {});
418
+ }
419
+ }
420
+
421
+ /** zcode flagship: check exactly the files this session's agents touched. */
422
+ async function selectSessionFiles() {
423
+ if (!devId.value || !sessionId.value) return;
424
+ busy.value = "session-files";
425
+ try {
426
+ const r = await deviceClient(devId.value).sessionGitFiles(sessionId.value);
427
+ sessionFiles.value = r.files;
428
+ const abs = (p: string) => (p.startsWith("/") || p.includes(":\\") ? p : `${cwd.value}/${p}`);
429
+ const touched = new Set(r.files.map(abs));
430
+ const mine = files.value.filter((f) => touched.has(abs(f.path)));
431
+ checked.value = new Set(mine.map((f) => f.path));
432
+ notify(mine.length ? `${mine.length} session file(s) still changed — checked` : "the session touched nothing that is still changed");
433
+ } catch (e) {
434
+ notify(`session files failed: ${(e as Error).message.slice(0, 120)}`);
435
+ } finally {
436
+ busy.value = "";
437
+ }
438
+ }
439
+
440
+ async function loadCommitFiles(hash: string) {
441
+ if (expanded.value.has(hash)) {
442
+ expanded.value.delete(hash);
443
+ expanded.value = new Map(expanded.value);
444
+ return;
445
+ }
446
+ if (!devId.value || !cwd.value) return;
447
+ try {
448
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "show-files", { target: hash });
449
+ expanded.value = new Map(expanded.value).set(hash, r.changed ?? []);
450
+ } catch (e) {
451
+ notify(`commit files failed: ${(e as Error).message.slice(0, 120)}`);
452
+ }
453
+ }
454
+
455
+ async function saveCheckpoint() {
456
+ if (!devId.value || !cwd.value) return;
457
+ const name = `cp-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`;
458
+ busy.value = "checkpoint";
459
+ try {
460
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "checkpoint", { name });
461
+ notify(r.text);
462
+ cpsOpen.value = true;
463
+ await loadCheckpoints();
464
+ } catch (e) {
465
+ notify(`checkpoint failed: ${(e as Error).message.slice(0, 120)}`);
466
+ } finally {
467
+ busy.value = "";
468
+ }
469
+ }
470
+
471
+ async function loadCheckpoints() {
472
+ if (!devId.value || !cwd.value) return;
473
+ try {
474
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "checkpoints");
475
+ checkpoints.value = r.checkpoints ?? [];
476
+ } catch {
477
+ checkpoints.value = [];
478
+ }
479
+ }
480
+
481
+ async function rewind(name: string) {
482
+ if (!devId.value || !cwd.value) return;
483
+ if (!confirm(`Rewind this worktree to checkpoint "${name}"? reset --hard — ALL uncommitted changes here are LOST.`)) return;
484
+ busy.value = "rewind";
485
+ try {
486
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "rewind", { name });
487
+ notify(r.text);
488
+ await refresh();
489
+ void loadLog(true);
490
+ } catch (e) {
491
+ notify(`rewind failed: ${(e as Error).message.slice(0, 120)}`);
492
+ } finally {
493
+ busy.value = "";
494
+ }
495
+ }
496
+ watch(cpsOpen, (open) => {
497
+ if (open) void loadCheckpoints();
498
+ });
499
+
176
500
  async function addWorktree(branch: string) {
177
501
  wtMenu.value = false;
178
502
  if (!devId.value || !cwd.value || !status.value?.root) return;
@@ -214,10 +538,38 @@ async function sessionOn(path: string, branch: string | null) {
214
538
  }
215
539
  }
216
540
 
541
+ /** orca worktree palette: jump to whatever session already lives there. */
542
+ async function goToWorktree(path: string) {
543
+ if (!devId.value) return;
544
+ busy.value = "go";
545
+ try {
546
+ const sessions = await deviceClient(devId.value).sessions();
547
+ const found = sessions.find((s) => s.cwd === path);
548
+ if (found) {
549
+ select({ deviceId: devId.value, sessionId: found.id, agentId: 1 });
550
+ notify(`switched to session "${found.name}"`);
551
+ } else {
552
+ await sessionOn(path, null);
553
+ }
554
+ } catch (e) {
555
+ notify(`switch failed: ${(e as Error).message.slice(0, 120)}`);
556
+ } finally {
557
+ busy.value = "";
558
+ }
559
+ }
560
+
217
561
  async function removeWorktree(path: string) {
218
562
  if (!devId.value || !cwd.value) return;
219
- if (!confirm(`Remove worktree ${path}?`)) return;
220
563
  busy.value = "wt";
564
+ let warn = "Uncommitted changes in it will be LOST.";
565
+ try {
566
+ // orca wording, earned honestly: only say "lost" when there ARE changes.
567
+ const batch = await deviceClient(devId.value).gitStatusBatch([path], { timeoutMs: 60_000 });
568
+ const s = batch[path];
569
+ if ((s?.staged ?? 0) + (s?.unstaged ?? 0) + (s?.untracked ?? 0) > 0) warn = `It CONTAINS uncommitted changes — they will be LOST.`;
570
+ else warn = "Its checkout is clean.";
571
+ } catch {}
572
+ if (!confirm(`Remove worktree ${path}? ${warn}`)) return;
221
573
  try {
222
574
  await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
223
575
  await refresh();
@@ -228,7 +580,8 @@ async function removeWorktree(path: string) {
228
580
  }
229
581
  }
230
582
 
231
- const commits = ref<{ hash: string; refs: string; author: string; date: string; subject: string }[]>([]);
583
+ type Commit = { hash: string; refs: string; author: string; date: string; subject: string; parents: string[] };
584
+ const commits = ref<Commit[]>([]);
232
585
  const logLoading = ref(false);
233
586
  const logError = ref("");
234
587
  let logAt = 0;
@@ -255,6 +608,44 @@ watch([devId, cwd], () => {
255
608
  void loadLog();
256
609
  });
257
610
  const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
611
+
612
+ /** zcode's portable lane layout: newest first, first parent keeps the lane. */
613
+ const LANE_COLORS = ["#4f8cc9", "#b8860b", "#7aa25c", "#a061ba", "#c96f6f", "#5fa8a0"];
614
+ const lanes = computed(() => {
615
+ const laneOf = new Map<string, number>();
616
+ const rows: { lane: number; parents: number[]; width: number }[] = [];
617
+ const alive: (string | null)[] = [];
618
+ for (const c of commits.value) {
619
+ let lane = laneOf.get(c.hash);
620
+ if (lane === undefined) {
621
+ lane = alive.indexOf(null);
622
+ if (lane === -1) {
623
+ alive.push(null);
624
+ lane = alive.length - 1;
625
+ }
626
+ laneOf.set(c.hash, lane);
627
+ }
628
+ alive[lane] = null;
629
+ const parentLanes: number[] = [];
630
+ c.parents.forEach((p, i) => {
631
+ let pl = laneOf.get(p);
632
+ if (pl === undefined || alive[pl] === p) {
633
+ pl = alive.indexOf(null);
634
+ if (pl === -1) {
635
+ alive.push(null);
636
+ pl = alive.length - 1;
637
+ }
638
+ laneOf.set(p, pl);
639
+ }
640
+ alive[pl] = p;
641
+ if (i === 0) alive[lane!] = p;
642
+ parentLanes.push(pl);
643
+ });
644
+ rows.push({ lane, parents: parentLanes, width: Math.max(1, alive.length) });
645
+ }
646
+ return rows;
647
+ });
648
+ const laneX = (lane: number) => lane * 12 + 7;
258
649
  </script>
259
650
 
260
651
  <template>
@@ -269,6 +660,12 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
269
660
  <p v-if="!status.repo" class="hint">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
270
661
 
271
662
  <template v-else>
663
+ <!-- conflict banner: the one state that blocks everything else -->
664
+ <p v-if="status.merge" class="message-error merge-banner">
665
+ merge in progress — {{ status.conflicts ?? conflictFiles.length }} unmerged path(s). Resolve them in the lists above, or
666
+ <button class="small" :disabled="!!busy" @click="gitAbort()">abort merge</button>
667
+ </p>
668
+
272
669
  <!-- branch popup + sync, the IDE way -->
273
670
  <div class="panel-heading git-top">
274
671
  <div class="branch-wrap">
@@ -280,17 +677,25 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
280
677
  <button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
281
678
  <GitBranch :size="13" /><span>{{ b.name }}</span>
282
679
  <span class="muted-line">{{ b.current ? "current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
680
+ <span class="grow" />
681
+ <GitCompare v-if="!b.current" :size="13" title="compare with this base" @click.stop="compareWith(b.name)" />
283
682
  </button>
284
683
  <div class="new-branch">
285
684
  <input v-model="newBranch" placeholder="new branch…" @keyup.enter="newBranch.trim() && switchBranch(newBranch.trim(), true)" />
286
685
  <button :disabled="!newBranch.trim()" @click="switchBranch(newBranch.trim(), true)"><Plus :size="13" /></button>
287
686
  </div>
687
+ <label class="auto-stash"><input type="checkbox" v-model="autoStash" /> auto-stash on switch</label>
288
688
  </div>
289
689
  </div>
290
690
  <span class="tag">{{ status.upstream ?? "no upstream" }}</span>
291
691
  <span v-if="status.ahead" class="tag">↑{{ status.ahead }}</span>
292
692
  <span v-if="status.behind" class="tag">↓{{ status.behind }}</span>
693
+ <span v-if="base" class="tag" title="vs the picked base">
694
+ vs {{ base.ref }}: ↑{{ base.ahead }} ↓{{ base.behind }}
695
+ <button class="tag-x" @click="base = null"><X :size="11" /></button>
696
+ </span>
293
697
  <span class="tag">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }}</span>
698
+ <a v-if="remoteWeb" class="icon-button" :href="`${remoteWeb}/tree/${status.branch ?? ''}`" target="_blank" rel="noopener" title="open on the remote"><ArrowUpFromLine :size="15" style="transform: rotate(45deg)" /></a>
294
699
  <button class="icon-button" :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="16" /></button>
295
700
  </div>
296
701
 
@@ -301,13 +706,27 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
301
706
  <button class="small" :disabled="!!busy" title="stash changes" @click="op('stash-push', { message: commitMsg.trim() || undefined })"><SquareStack :size="14" />stash</button>
302
707
  <button class="small" :disabled="!!busy" title="pop the newest stash" @click="op('stash-pop')"><PackageOpen :size="14" />unstash</button>
303
708
  </div>
709
+ <p v-if="pushHint" class="message-error hintbox">
710
+ {{ pushHint }}
711
+ <button v-if="pushHint.includes('upstream')" class="small primary" :disabled="!!busy" @click="op('push', { setUpstream: true, ref: status.branch })">push -u origin {{ status.branch }}</button>
712
+ <button v-else-if="pushHint.includes('remote has work')" class="small primary" :disabled="!!busy" @click="op('pull')">pull now</button>
713
+ </p>
714
+ <p v-if="pullHint" class="message-error hintbox">
715
+ fast-forward is not possible — branches diverged.
716
+ <button class="small primary" :disabled="!!busy" @click="op('pull', { rebase: true })">pull --rebase</button>
717
+ </p>
304
718
 
305
719
  <!-- COMMIT — always present, changelist semantics -->
306
720
  <section class="task-section" aria-label="Commit">
307
721
  <div class="panel-heading">
308
722
  <h4>Commit</h4>
309
723
  <span class="tag">{{ files.length ? `${checked.size}/${files.length} files` : "no changes" }}</span>
724
+ <span class="grow" />
725
+ <button v-if="sessionId" class="small" :disabled="!!busy" title="check the files this session's agent touched" @click="selectSessionFiles">
726
+ <Flag :size="13" /> session files{{ sessionCount ? ` (${sessionCount.changed}/${sessionCount.total})` : "" }}
727
+ </button>
310
728
  </div>
729
+ <p v-if="noIdentity" class="hintbox warn">git user.name/user.email is not set on the device — <code>dpi device config</code> or <code>git config --global</code> before committing.</p>
311
730
  <p v-if="atCap" class="muted-line cap">showing the first 500 changed files — commit in smaller batches</p>
312
731
  <div v-if="stagedFiles.length" class="files">
313
732
  <label class="file all">
@@ -315,69 +734,129 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
315
734
  <span class="muted-line">Staged · {{ stagedFiles.length }}</span>
316
735
  <span class="grow" />
317
736
  <button class="icon-button" title="stage every changed file" @click="fileAction('stage')"><Plus :size="13" /> all</button>
737
+ <button class="icon-button" title="unstage all (git reset HEAD)" @click="unstageFiles(stagedFiles.map((f) => f.path))"><Minus :size="13" /> none</button>
318
738
  </label>
319
- <div v-for="f in stagedFiles" :key="f.path" class="file">
320
- <label class="file-label">
321
- <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
322
- <code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
323
- <span class="path" :title="f.path">{{ f.path }}</span>
324
- </label>
325
- <span class="file-acts">
326
- <button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
327
- <button class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
328
- </span>
329
- </div>
739
+ <template v-for="g in grouped(stagedFiles)" :key="g.dir">
740
+ <button v-if="g.dir !== '.'" class="dir-row" @click="collapse(g.dir)">
741
+ <ChevronRight :size="12" :class="{ open: g.open }" />{{ g.dir }}<span class="muted-line">· {{ g.files.length }}</span>
742
+ </button>
743
+ <div v-for="f in g.files" v-show="g.open" :key="f.path" class="file">
744
+ <label class="file-label">
745
+ <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
746
+ <code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
747
+ <span class="path" :title="f.path">{{ f.path }}</span>
748
+ <span v-if="numChip(f)" class="nums" :class="{ bin: numChip(f) === 'bin' }">{{ numChip(f) }}</span>
749
+ </label>
750
+ <span class="file-acts">
751
+ <button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
752
+ <button class="icon-button" title="unstage (git reset HEAD)" @click="unstageFiles([f.path])"><Minus :size="13" /></button>
753
+ <button class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
754
+ </span>
755
+ </div>
756
+ </template>
330
757
  </div>
331
758
  <div v-if="unstagedFiles.length" class="files">
332
759
  <label class="file all">
333
760
  <input type="checkbox" :checked="unstagedFiles.every((f) => checked.has(f.path))" @change="toggleGroup(unstagedFiles)" />
334
761
  <span class="muted-line">Unstaged · {{ unstagedFiles.length }}</span>
335
762
  </label>
336
- <div v-for="f in unstagedFiles" :key="f.path" class="file">
337
- <label class="file-label">
338
- <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
339
- <code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
340
- <span class="path" :title="f.path">{{ f.path }}</span>
341
- </label>
342
- <span class="file-acts">
343
- <button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
344
- <button class="icon-button" title="stage (git add)" @click="fileAction('stage', f.path)"><Plus :size="13" /></button>
345
- <button class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
346
- </span>
347
- </div>
763
+ <template v-for="g in grouped(unstagedFiles)" :key="g.dir">
764
+ <button v-if="g.dir !== '.'" class="dir-row" @click="collapse(g.dir)">
765
+ <ChevronRight :size="12" :class="{ open: g.open }" />{{ g.dir }}<span class="muted-line">· {{ g.files.length }}</span>
766
+ </button>
767
+ <div v-for="f in g.files" v-show="g.open" :key="f.path" class="file">
768
+ <label class="file-label">
769
+ <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
770
+ <code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
771
+ <span class="path" :title="f.path">{{ f.path }}</span>
772
+ <span v-if="numChip(f)" class="nums" :class="{ bin: numChip(f) === 'bin' }">{{ numChip(f) }}</span>
773
+ </label>
774
+ <span class="file-acts">
775
+ <button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
776
+ <button v-if="f.x === '?'" class="icon-button" title="add to .gitignore" @click="ignorePaths([f.path])"><Ban :size="13" /></button>
777
+ <button v-else class="icon-button" title="stage (git add)" @click="fileAction('stage', f.path)"><Plus :size="13" /></button>
778
+ <button v-if="f.x !== '?'" class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
779
+ <button class="icon-button" title="copy path" @click="copyPath(f.path)"><Copy :size="13" /></button>
780
+ </span>
781
+ </div>
782
+ </template>
348
783
  </div>
349
784
  <p v-if="fileDiff" class="file-diff">
350
785
  <strong>{{ fileDiff.path }}</strong>
786
+ <span class="mode-chips">
787
+ <button :class="{ on: diffMode === 'working' }" @click="diffMode = 'working'; reDiff()">working</button>
788
+ <button :class="{ on: diffMode === 'staged' }" @click="diffMode = 'staged'; reDiff()">staged</button>
789
+ </span>
790
+ <span v-if="diffIsBinary" class="nums bin">binary file</span>
791
+ <span v-else-if="fileDiff.text.length > 8000" class="nums">truncated at 8000 chars</span>
351
792
  <button class="icon-button" title="close diff" @click="fileDiff = null"><X :size="13" /></button>
352
- <pre>{{ fileDiff.text.slice(0, 8000) }}</pre>
793
+ <pre v-if="!diffIsBinary">{{ fileDiff.text.slice(0, 8000) }}</pre>
353
794
  </p>
354
795
  <textarea
355
796
  v-model="commitMsg"
356
797
  rows="4"
357
798
  placeholder="Commit message… (Ctrl+Enter to commit)"
358
- @keydown.enter.exact.prevent="commit"
359
- @keydown.meta.enter.prevent="commit"
360
- @keydown.ctrl.enter.prevent="commit"
799
+ @keydown.enter.exact.prevent="commit()"
800
+ @keydown.meta.enter.prevent="commit()"
801
+ @keydown.ctrl.enter.prevent="commit()"
361
802
  />
362
- <button class="primary small" :disabled="!!busy || !commitMsg.trim() || !someChecked" @click="commit">
363
- <Check :size="14" /> Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : "" }}
364
- </button>
803
+ <div class="row">
804
+ <button class="small" :disabled="!!busy || !checked.size" title="ask the device's model for a message" @click="aiMessage"><Sparkles :size="14" /> ai message</button>
805
+ <span class="grow" />
806
+ <button class="primary small" :disabled="!!busy || !commitMsg.trim() || !someChecked" @click="commit(false)">
807
+ <Check :size="14" /> Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : "" }}
808
+ </button>
809
+ <button v-if="status.upstream" class="primary small" :disabled="!!busy || !commitMsg.trim() || !someChecked" title="commit, then push" @click="commit(true)">
810
+ <Check :size="14" /> Commit &amp; push
811
+ </button>
812
+ </div>
365
813
  </section>
366
814
 
367
- <!-- LOG -->
815
+ <!-- LOG: swimlane graph + expandable commits + checkpoints -->
368
816
  <section class="task-section" aria-label="Log">
369
817
  <div class="panel-heading">
370
818
  <h4><History :size="16" /> Log</h4>
819
+ <span class="grow" />
820
+ <button class="small" :disabled="!!busy" title="save a checkpoint ref of HEAD" @click="saveCheckpoint"><Flag :size="13" /> checkpoint</button>
821
+ <button class="small" :disabled="!!busy" @click="cpsOpen = !cpsOpen">checkpoints {{ checkpoints.length ? `(${checkpoints.length})` : "" }}</button>
371
822
  <button class="icon-button" :disabled="logLoading" title="refresh log" @click="loadLog(true)"><RefreshCw :size="14" /></button>
372
823
  </div>
824
+ <div v-if="cpsOpen" class="cps">
825
+ <p v-if="!checkpoints.length" class="hint">no checkpoints yet — one click saves a named ref of HEAD.</p>
826
+ <div v-for="cp in checkpoints" :key="cp.name" class="cp-row">
827
+ <code>{{ cp.name }}</code>
828
+ <span class="muted-line">{{ cp.hash }} · {{ cp.date }}</span>
829
+ <span class="cp-subj">{{ cp.subject }}</span>
830
+ <span class="grow" />
831
+ <button class="small" :disabled="!!busy" title="reset --hard to this checkpoint (this worktree)" @click="rewind(cp.name)"><Undo2 :size="13" /> rewind</button>
832
+ </div>
833
+ </div>
373
834
  <p v-if="logLoading && !commits.length" class="hint">loading log…</p>
374
835
  <div v-else-if="commits.length" class="commits">
375
- <div v-for="c in commits" :key="c.hash" class="commit-row" :title="c.refs">
376
- <code class="hash">{{ c.hash }}</code>
377
- <span v-if="c.refs" class="refs">{{ c.refs.split(",")[0] }}</span>
378
- <span class="subject">{{ c.subject }}</span>
379
- <span class="muted-line who">{{ c.author }} · {{ c.date }}</span>
380
- </div>
836
+ <template v-for="(c, i) in commits" :key="c.hash">
837
+ <div class="commit-row" :title="c.refs">
838
+ <svg v-if="lanes[i]" class="lane" :width="lanes[i].width * 12 + 4" height="24" aria-hidden="true">
839
+ <line v-for="l in lanes[i].width" :key="l" :x1="laneX(l - 1)" :x2="laneX(l - 1)" y1="0" y2="24" :stroke="'#3a3134'" stroke-width="2" />
840
+ <line v-for="(pl, j) in lanes[i].parents" :key="'p' + j" :x1="laneX(lanes[i].lane)" :x2="laneX(pl)" y1="12" y2="24" :stroke="LANE_COLORS[pl % LANE_COLORS.length]" stroke-width="2" />
841
+ <circle :cx="laneX(lanes[i].lane)" cy="12" r="4" :fill="LANE_COLORS[lanes[i].lane % LANE_COLORS.length]" />
842
+ </svg>
843
+ <button class="icon-button hash-btn" title="files in this commit" @click="loadCommitFiles(c.hash)"><ChevronRight :size="12" :class="{ open: expanded.has(c.hash) }" /></button>
844
+ <code class="hash">
845
+ <a v-if="remoteWeb" :href="`${remoteWeb}/commit/${c.hash}`" target="_blank" rel="noopener" title="on the remote">{{ c.hash }}</a>
846
+ <template v-else>{{ c.hash }}</template>
847
+ </code>
848
+ <span v-if="c.refs" class="refs">{{ c.refs.split(",")[0] }}</span>
849
+ <span class="subject">{{ c.subject }}</span>
850
+ <span class="muted-line who">{{ c.author }} · {{ c.date }}</span>
851
+ </div>
852
+ <div v-if="expanded.has(c.hash)" class="commit-files">
853
+ <div v-for="f in expanded.get(c.hash)" :key="f.path" class="cf">
854
+ <code class="cf-status" :data-s="f.status">{{ f.status }}</code>
855
+ <span class="path">{{ f.path }}</span>
856
+ </div>
857
+ <p v-if="!expanded.get(c.hash)?.length" class="hint">no files (merge/empty commit)</p>
858
+ </div>
859
+ </template>
381
860
  </div>
382
861
  <p v-else-if="logError" class="message-error">log failed: {{ logError }}</p>
383
862
  <p v-else class="hint">no commits</p>
@@ -404,6 +883,7 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
404
883
  <code :title="w.path">{{ w.path }}</code>
405
884
  <span class="muted-line">{{ w.branch ?? w.head }}</span>
406
885
  <span class="grow" />
886
+ <button class="icon-button" :disabled="!!busy" title="jump to this worktree (session or new)" @click="goToWorktree(w.path)"><ArrowRight :size="13" /></button>
407
887
  <button class="small" :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
408
888
  <button v-if="w.path !== status.root" class="icon-button" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="13" /></button>
409
889
  <Check v-if="w.path === status.root" :size="13" class="muted-line" />
@@ -432,29 +912,51 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
432
912
  .menu button:hover, .menu button.cur { background: #403536; }
433
913
  .menu .new-branch { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
434
914
  .menu .new-branch input { flex: 1; min-width: 0; }
915
+ .auto-stash { display: flex; gap: 8px; align-items: center; padding: 8px 10px; font-size: 14px; opacity: 0.85; }
435
916
  .muted-line { opacity: 0.65; font-size: 13px; }
436
917
  .cap { padding: 4px 0; }
437
- .row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
438
- .files { max-height: 220px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; }
918
+ .row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; align-items: center; }
919
+ .hintbox { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 6px 10px; border-radius: 6px; margin: 4px 0; }
920
+ .hintbox.warn { border: 1px solid var(--warn, #e5a50a); color: var(--warn, #e5a50a); }
921
+ .merge-banner { padding: 8px 10px; border-radius: 6px; }
922
+ .files { max-height: 260px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; }
439
923
  .file { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 3px 0; }
440
924
  .file.all { border-bottom: 1px solid var(--line); }
441
925
  .file-label { display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0; cursor: pointer; }
442
926
  .file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
443
927
  .file .xy { opacity: 0.7; font-size: 12px; }
928
+ .nums { font-size: 11px; opacity: 0.8; color: var(--ok, #2ea043); white-space: nowrap; }
929
+ .nums.bin { color: var(--warn, #e5a50a); }
930
+ .dir-row { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 0; font-size: 13px; opacity: 0.8; }
931
+ .dir-row .open { transform: rotate(90deg); }
444
932
  .file-acts { display: flex; gap: 2px; }
445
- .file-diff { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
446
- .file-diff pre { max-height: 260px; overflow: auto; font-size: 12px; margin: 6px 0 0; }
933
+ .file-diff { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
934
+ .file-diff pre { max-height: 260px; overflow: auto; font-size: 12px; margin: 6px 0 0; width: 100%; }
935
+ .mode-chips { display: inline-flex; gap: 2px; }
936
+ .mode-chips button { font-size: 12px; padding: 2px 8px; border-radius: 10px; border: 1px solid var(--line); }
937
+ .mode-chips button.on { background: #403536; }
447
938
  textarea { width: 100%; resize: vertical; min-height: 76px; font: inherit; margin-top: 8px; }
448
- .commits { max-height: 320px; overflow: auto; display: grid; gap: 3px; }
449
- .commit-row { display: flex; gap: 8px; align-items: baseline; white-space: nowrap; }
939
+ .commits { max-height: 340px; overflow: auto; display: grid; gap: 3px; }
940
+ .commit-row { display: flex; gap: 8px; align-items: center; white-space: nowrap; }
941
+ .commit-row .lane { flex-shrink: 0; }
450
942
  .commit-row .hash { color: var(--ok, #2ea043); font-size: 13px; }
943
+ .hash-btn svg { transition: transform 0.1s; }
944
+ .hash-btn svg.open { transform: rotate(90deg); }
451
945
  .commit-row .refs { color: var(--warn, #e5a50a); font-size: 12px; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
452
- .commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 42%; font-size: 14px; }
946
+ .commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 40%; font-size: 14px; }
453
947
  .commit-row .who { font-size: 12px; }
948
+ .commit-files { padding: 2px 0 6px 34px; border-left: 2px solid var(--line); margin-left: 16px; }
949
+ .cf { display: flex; gap: 10px; font-size: 13px; padding: 1px 0; }
950
+ .cf-status { font-size: 11px; opacity: 0.8; width: 2ch; }
951
+ .cps { border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; margin-bottom: 6px; }
952
+ .cp-row { display: flex; gap: 10px; align-items: center; padding: 2px 0; font-size: 14px; }
953
+ .cp-row code { color: var(--warn, #e5a50a); }
954
+ .cp-subj { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
454
955
  .wt { display: flex; gap: 8px; align-items: center; padding: 2px 0; }
455
- .wt code { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44%; }
956
+ .wt code { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
456
957
  .wt.main code { font-weight: 650; }
457
958
  .grow { flex: 1; }
959
+ .tag-x { margin-left: 4px; opacity: 0.7; display: inline-flex; }
458
960
  .op-out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 10px; border-radius: 6px; font-size: 12px; margin-top: 8px; }
459
961
  h4 { display: flex; align-items: center; gap: 8px; font-size: 16px; margin: 0; }
460
962
  </style>