my-durable 0.0.24 → 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,12 +1,36 @@
1
1
  <script setup lang="ts">
2
- // Inspector GIT tab, IntelliJ-shaped:
3
- // • branch PICKER — the list is right there, nobody types an existing name
4
- // • worktrees are first-class rows: create a SESSION on one click
5
- // • commit: big multi-line box + changed-file checkboxes (commit chosen files)
6
- // Lazy: rides the shared git-store (one fetch per folder while fresh), the
7
- // refresh button and every op are the only forced refetches.
2
+ // Inspector GIT tab — the IDE git window, in the app's own design language
3
+ // (panel-heading + tags + task-sections). IntelliJ features: branch popup,
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).
8
9
  import { computed, onServerPrefetch, ref, watch } from "vue";
9
- import { Check, ChevronDown, RefreshCw, X } from "lucide-vue-next";
10
+ import {
11
+ ArrowDownToLine,
12
+ ArrowRight,
13
+ ArrowUpFromLine,
14
+ Ban,
15
+ Check,
16
+ ChevronDown,
17
+ ChevronRight,
18
+ Copy,
19
+ CornerUpLeft,
20
+ Eye,
21
+ Flag,
22
+ GitBranch,
23
+ GitCompare,
24
+ History,
25
+ Minus,
26
+ PackageOpen,
27
+ Plus,
28
+ RefreshCw,
29
+ Sparkles,
30
+ SquareStack,
31
+ Undo2,
32
+ X,
33
+ } from "lucide-vue-next";
10
34
  import { deviceId, network, sessionSummary, notify, select } from "../client/store";
11
35
  import { deviceClient } from "../client/hub";
12
36
  import { ensureGit, gitEntry } from "../client/git-store";
@@ -21,53 +45,83 @@ const status = computed(() => entry.value.status);
21
45
  const loading = computed(() => entry.value.loading);
22
46
  const refreshing = computed(() => entry.value.refreshing);
23
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
+ });
24
52
 
25
53
  const busy = ref("");
26
54
  const out = ref("");
27
- /** IntelliJ's log window: lazily fetched once per folder, explicit refresh. */
28
- const commits = ref<{ hash: string; refs: string; author: string; date: string; subject: string }[]>([]);
29
- const logLoading = ref(false);
30
- let logAt = 0;
31
- async function loadLog(force = false) {
32
- if (!devId.value || !cwd.value) return;
33
- if (!force && commits.value.length && Date.now() - logAt < 120_000) return;
34
- logLoading.value = true;
35
- try {
36
- const r = await deviceClient(devId.value).gitOp(cwd.value, "log-list");
37
- commits.value = r.commits ?? [];
38
- logAt = Date.now();
39
- } catch {
40
- /* the log is decorative; the ops row surfaces failures */
41
- } finally {
42
- logLoading.value = false;
43
- }
44
- }
45
- if (!import.meta.env.SSR) void loadLog();
46
- watch([devId, cwd], () => {
47
- commits.value = [];
48
- logAt = 0;
49
- void loadLog();
50
- });
51
55
  const commitMsg = ref("");
52
56
  const checked = ref<Set<string>>(new Set());
53
57
  const branchMenu = ref(false);
54
58
  const wtMenu = ref(false);
55
59
  const newBranch = ref("");
60
+ /** IntelliJ: click a changed file → its diff, right there. */
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());
56
89
 
57
- 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
+ }
58
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
+ });
59
104
  watch([devId, cwd], () => {
60
105
  branchMenu.value = false;
61
106
  wtMenu.value = false;
62
- void ensureGit(devId.value || "?", cwd.value || "?");
107
+ fileDiff.value = null;
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
+ }
63
118
  });
64
119
  const refresh = () => {
65
120
  checked.value = new Set();
121
+ fileDiff.value = null;
66
122
  return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
67
123
  };
68
124
 
69
- // changed files: staged+modified default-checked, untracked not — IntelliJ's
70
- // changelist behavior.
71
125
  const files = computed(() => status.value?.files ?? []);
72
126
  watch(files, (list) => {
73
127
  const next = new Set<string>();
@@ -75,6 +129,18 @@ watch(files, (list) => {
75
129
  checked.value = next;
76
130
  });
77
131
  const someChecked = computed(() => files.value.some((f) => checked.value.has(f.path)));
132
+ /** orca's split: staged (index changed) vs unstaged (worktree + untracked). */
133
+ const stagedFiles = computed(() => files.value.filter((f) => f.x !== " " && f.x !== "?"));
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)));
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
+ });
78
144
  const toggle = (p: string) => {
79
145
  const next = new Set(checked.value);
80
146
  if (next.has(p)) next.delete(p);
@@ -84,51 +150,227 @@ const toggle = (p: string) => {
84
150
  const toggleAll = () => {
85
151
  checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
86
152
  };
153
+ const toggleGroup = (group: { path: string }[]) => {
154
+ const all = group.every((f) => checked.value.has(f.path));
155
+ const next = new Set(checked.value);
156
+ for (const f of group) {
157
+ if (all) next.delete(f.path);
158
+ else next.add(f.path);
159
+ }
160
+ checked.value = next;
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
+ };
87
190
 
88
- async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
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
+ ) {
89
195
  if (!devId.value || !cwd.value) return;
90
196
  busy.value = name;
91
197
  out.value = "";
92
198
  try {
93
- const r = await deviceClient(devId.value).gitOp(cwd.value, name);
199
+ const r = await deviceClient(devId.value).gitOp(cwd.value, name, extra);
94
200
  out.value = r.text || "(empty)";
95
- if (name === "fetch" || name === "pull") notify(`git ${name} ✓`);
201
+ if (name === "push") {
202
+ pushHint.value = "";
203
+ }
204
+ if (name === "pull") pullHint.value = false;
205
+ if (["fetch", "pull", "push", "stash-push", "stash-pop"].includes(name)) notify(`git ${name} ✓`);
206
+ await refresh();
207
+ void loadLog(true);
208
+ } catch (e) {
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)}`);
219
+ } finally {
220
+ busy.value = "";
221
+ }
222
+ }
223
+
224
+ async function fileAction(name: "stage" | "discard", path?: string) {
225
+ if (!devId.value || !cwd.value) return;
226
+ if (!path && name !== "stage") return;
227
+ if (name === "discard" && path && !confirm(`Roll back ${path} to HEAD? Uncommitted changes are lost.`)) return;
228
+ busy.value = name;
229
+ const targets = path ? [path] : files.value.filter((f) => f.x === " " || f.x === "M").map((f) => f.path);
230
+ try {
231
+ const r = await deviceClient(devId.value).gitOp(cwd.value, name, { files: targets });
232
+ notify(r.text);
233
+ await refresh();
234
+ } catch (e) {
235
+ notify(`${name} failed: ${(e as Error).message.slice(0, 140)}`);
236
+ } finally {
237
+ busy.value = "";
238
+ }
239
+ }
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);
96
247
  await refresh();
97
- if (name === "fetch" || name === "pull" || name === "branch") void loadLog(true);
98
248
  } catch (e) {
99
- out.value = `${name} failed: ${(e as Error).message.slice(0, 200)}`;
249
+ notify(`unstage failed: ${(e as Error).message.slice(0, 140)}`);
100
250
  } finally {
101
251
  busy.value = "";
102
252
  }
103
253
  }
104
254
 
105
- async function switchBranch(name: string) {
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
+
269
+ async function showDiff(path: string) {
270
+ if (!devId.value || !cwd.value) return;
271
+ if (fileDiff.value?.path === path) return (fileDiff.value = null);
272
+ busy.value = "diff";
273
+ try {
274
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "diff-file", { target: path, staged: diffMode.value === "staged" });
275
+ fileDiff.value = { path, text: r.text };
276
+ } catch (e) {
277
+ notify(`diff failed: ${(e as Error).message.slice(0, 140)}`);
278
+ } finally {
279
+ busy.value = "";
280
+ }
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
+ }
316
+
317
+ async function switchBranch(name: string, create = false) {
106
318
  branchMenu.value = false;
107
- if (!devId.value || !cwd.value || name === status.value?.branch) return;
319
+ if (!devId.value || !cwd.value || (name === status.value?.branch && !create)) return;
108
320
  busy.value = "switch";
321
+ const client = deviceClient(devId.value);
322
+ let stashed = false;
109
323
  try {
110
- const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name });
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 });
111
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
+ }
112
339
  await refresh();
340
+ void loadLog(true);
113
341
  } catch (e) {
114
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
+ }
115
348
  } finally {
116
349
  busy.value = "";
117
350
  }
118
351
  }
119
352
 
120
- async function commit() {
353
+ async function commit(thenPush = false) {
121
354
  if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
122
355
  busy.value = "commit";
123
356
  const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
124
357
  if (!chosen.length) return notify("check the files to commit — nothing is selected");
358
+ const client = deviceClient(devId.value);
125
359
  try {
126
- const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
360
+ const r = await client.gitOp(cwd.value, "commit", {
127
361
  message: commitMsg.value.trim(),
128
362
  files: chosen,
129
363
  });
130
364
  notify(r.text);
131
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
+ }
132
374
  await refresh();
133
375
  void loadLog(true);
134
376
  } catch (e) {
@@ -138,6 +380,123 @@ async function commit() {
138
380
  }
139
381
  }
140
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
+
141
500
  async function addWorktree(branch: string) {
142
501
  wtMenu.value = false;
143
502
  if (!devId.value || !cwd.value || !status.value?.root) return;
@@ -147,10 +506,7 @@ async function addWorktree(branch: string) {
147
506
  const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
148
507
  busy.value = "wt";
149
508
  try {
150
- const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", {
151
- branch: branch, // existing branch checks out; new name creates it
152
- target,
153
- });
509
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", { branch, target });
154
510
  notify(r.text.split("\n")[0] ?? `worktree ${target}`);
155
511
  newBranch.value = "";
156
512
  await refresh();
@@ -161,14 +517,12 @@ async function addWorktree(branch: string) {
161
517
  }
162
518
  }
163
519
 
164
- /** A worktree is a working copy — one click and it is a SESSION (herd flow). */
165
520
  async function sessionOn(path: string, branch: string | null) {
166
521
  if (!devId.value) return;
167
522
  busy.value = "session";
168
523
  try {
169
524
  const info = network.devices.value.find((d) => d.id === devId.value)?.info;
170
- const model =
171
- (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
525
+ const model = (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
172
526
  if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
173
527
  const s = await deviceClient(devId.value).createSession({
174
528
  name: branch ?? path.slice(path.lastIndexOf("/") + 1),
@@ -184,10 +538,38 @@ async function sessionOn(path: string, branch: string | null) {
184
538
  }
185
539
  }
186
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
+
187
561
  async function removeWorktree(path: string) {
188
562
  if (!devId.value || !cwd.value) return;
189
- if (!confirm(`Remove worktree ${path}?`)) return;
190
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;
191
573
  try {
192
574
  await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
193
575
  await refresh();
@@ -197,117 +579,318 @@ async function removeWorktree(path: string) {
197
579
  busy.value = "";
198
580
  }
199
581
  }
582
+
583
+ type Commit = { hash: string; refs: string; author: string; date: string; subject: string; parents: string[] };
584
+ const commits = ref<Commit[]>([]);
585
+ const logLoading = ref(false);
586
+ const logError = ref("");
587
+ let logAt = 0;
588
+ async function loadLog(force = false) {
589
+ if (!devId.value || !cwd.value) return;
590
+ if (!force && commits.value.length && Date.now() - logAt < 120_000) return;
591
+ logLoading.value = true;
592
+ try {
593
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "log-list");
594
+ commits.value = r.commits ?? [];
595
+ logError.value = "";
596
+ logAt = Date.now();
597
+ } catch (e) {
598
+ // "no commits" lied when the fetch failed (checklist Wave 1a) — say it.
599
+ logError.value = (e as Error).message.slice(0, 120);
600
+ } finally {
601
+ logLoading.value = false;
602
+ }
603
+ }
604
+ if (!import.meta.env.SSR) void loadLog();
605
+ watch([devId, cwd], () => {
606
+ commits.value = [];
607
+ logAt = 0;
608
+ void loadLog();
609
+ });
200
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;
201
649
  </script>
202
650
 
203
651
  <template>
204
- <div class="git-tab">
205
- <p v-if="!cwd" class="empty">No session selected — open a session to see its git.</p>
652
+ <div class="git-pane">
653
+ <p v-if="!cwd" class="hint">No session selected — open a session to see its git.</p>
206
654
  <template v-else>
207
- <header class="git-head">
208
- <strong>git</strong>
209
- <code class="cwd" :title="cwd">{{ cwd }}</code>
210
- <button :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
211
- </header>
212
-
213
- <p v-if="loading" class="empty">loading git…</p>
214
- <p v-else-if="refreshing" class="muted note">refreshing…</p>
215
- <p v-if="error" class="error">{{ error }} <button :disabled="!!busy" @click="refresh()">retry</button></p>
655
+ <p v-if="loading" class="hint">loading git…</p>
656
+ <p v-else-if="refreshing" class="muted-line">refreshing…</p>
657
+ <p v-if="error" class="message-error">{{ error }} <button @click="refresh()">retry</button></p>
216
658
 
217
659
  <div v-if="status" :class="{ stale: !!error || !!refreshing }">
218
- <p v-if="!status.repo" class="empty">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
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>
219
661
 
220
662
  <template v-else>
221
- <!-- branch bar: PICK, never type -->
222
- <div class="branch-bar">
223
- <button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
224
- <strong>{{ status.branch ?? "detached" }}</strong>
225
- <span v-if="status.upstream" class="muted">→ {{ status.upstream }}</span>
226
- <span v-if="status.ahead || status.behind" class="muted">↑{{ status.ahead ?? 0 }} ↓{{ status.behind ?? 0 }}</span>
227
- <ChevronDown :size="14" />
228
- </button>
229
- <span class="muted">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }} · {{ status.head }}</span>
230
- <div v-if="branchMenu" class="menu">
231
- <button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
232
- <span>{{ b.name }}</span>
233
- <span class="muted">{{ b.current ? "● current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
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
+
669
+ <!-- branch popup + sync, the IDE way -->
670
+ <div class="panel-heading git-top">
671
+ <div class="branch-wrap">
672
+ <button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
673
+ <GitBranch :size="16" /><strong>{{ status.branch ?? "detached" }}</strong>
674
+ <ChevronDown :size="14" />
234
675
  </button>
235
- <p v-if="!(status.branches ?? []).length" class="muted pad">no local branches</p>
676
+ <div v-if="branchMenu" class="menu">
677
+ <button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
678
+ <GitBranch :size="13" /><span>{{ b.name }}</span>
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)" />
682
+ </button>
683
+ <div class="new-branch">
684
+ <input v-model="newBranch" placeholder="new branch…" @keyup.enter="newBranch.trim() && switchBranch(newBranch.trim(), true)" />
685
+ <button :disabled="!newBranch.trim()" @click="switchBranch(newBranch.trim(), true)"><Plus :size="13" /></button>
686
+ </div>
687
+ <label class="auto-stash"><input type="checkbox" v-model="autoStash" /> auto-stash on switch</label>
688
+ </div>
236
689
  </div>
690
+ <span class="tag">{{ status.upstream ?? "no upstream" }}</span>
691
+ <span v-if="status.ahead" class="tag">↑{{ status.ahead }}</span>
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>
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>
699
+ <button class="icon-button" :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="16" /></button>
237
700
  </div>
238
701
 
239
702
  <div class="row ops">
240
- <button :disabled="!!busy" @click="op('fetch')">fetch</button>
241
- <button :disabled="!!busy" @click="op('pull')">pull</button>
242
- <button :disabled="!!busy" @click="op('branch')">branches</button>
243
- <button :disabled="!!busy" @click="op('diff')">diff</button>
703
+ <button class="small" :disabled="!!busy" @click="op('fetch')"><ArrowDownToLine :size="14" />fetch</button>
704
+ <button class="small" :disabled="!!busy" @click="op('pull')"><ArrowDownToLine :size="14" />pull</button>
705
+ <button class="small primary" :disabled="!!busy" title="push" @click="op('push')"><ArrowUpFromLine :size="14" />push</button>
706
+ <button class="small" :disabled="!!busy" title="stash changes" @click="op('stash-push', { message: commitMsg.trim() || undefined })"><SquareStack :size="14" />stash</button>
707
+ <button class="small" :disabled="!!busy" title="pop the newest stash" @click="op('stash-pop')"><PackageOpen :size="14" />unstash</button>
244
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>
245
718
 
246
- <!-- COMMIT: always present — IntelliJ's Ctrl+K opens on a clean tree too -->
247
- <section class="commit">
248
- <div class="sec-head"><strong>Commit</strong><span class="muted">{{ files.length ? `${checked.size}/${files.length} files` : "no changes" }}</span></div>
249
- <div v-if="files.length" class="files">
719
+ <!-- COMMIT — always present, changelist semantics -->
720
+ <section class="task-section" aria-label="Commit">
721
+ <div class="panel-heading">
722
+ <h4>Commit</h4>
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>
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>
730
+ <p v-if="atCap" class="muted-line cap">showing the first 500 changed files — commit in smaller batches</p>
731
+ <div v-if="stagedFiles.length" class="files">
250
732
  <label class="file all">
251
- <input type="checkbox" :checked="someChecked" @change="toggleAll" />
252
- <span class="muted">select all / none</span>
733
+ <input type="checkbox" :checked="stagedFiles.every((f) => checked.has(f.path))" @change="toggleGroup(stagedFiles)" />
734
+ <span class="muted-line">Staged · {{ stagedFiles.length }}</span>
735
+ <span class="grow" />
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>
253
738
  </label>
254
- <label v-for="f in files" :key="f.path" class="file">
255
- <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
256
- <code class="xy">{{ f.x }}{{ f.y }}</code>
257
- <span class="path" :title="f.path">{{ f.path }}</span>
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>
757
+ </div>
758
+ <div v-if="unstagedFiles.length" class="files">
759
+ <label class="file all">
760
+ <input type="checkbox" :checked="unstagedFiles.every((f) => checked.has(f.path))" @change="toggleGroup(unstagedFiles)" />
761
+ <span class="muted-line">Unstaged · {{ unstagedFiles.length }}</span>
258
762
  </label>
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>
783
+ </div>
784
+ <p v-if="fileDiff" class="file-diff">
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>
792
+ <button class="icon-button" title="close diff" @click="fileDiff = null"><X :size="13" /></button>
793
+ <pre v-if="!diffIsBinary">{{ fileDiff.text.slice(0, 8000) }}</pre>
794
+ </p>
795
+ <textarea
796
+ v-model="commitMsg"
797
+ rows="4"
798
+ placeholder="Commit message… (Ctrl+Enter to commit)"
799
+ @keydown.enter.exact.prevent="commit()"
800
+ @keydown.meta.enter.prevent="commit()"
801
+ @keydown.ctrl.enter.prevent="commit()"
802
+ />
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>
259
812
  </div>
260
- <textarea v-model="commitMsg" rows="4" placeholder="Commit message…" />
261
- <button class="primary" :disabled="!!busy || !commitMsg.trim() || !someChecked" @click="commit">
262
- Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : "" }}
263
- </button>
264
813
  </section>
265
814
 
266
- <!-- LOG: the IDE centerpiece — hash, refs, author, date, subject -->
267
- <section class="logsec">
268
- <div class="sec-head">
269
- <strong>Log</strong>
270
- <button :disabled="logLoading" title="refresh log" @click="loadLog(true)">↻</button>
815
+ <!-- LOG: swimlane graph + expandable commits + checkpoints -->
816
+ <section class="task-section" aria-label="Log">
817
+ <div class="panel-heading">
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>
822
+ <button class="icon-button" :disabled="logLoading" title="refresh log" @click="loadLog(true)"><RefreshCw :size="14" /></button>
271
823
  </div>
272
- <p v-if="logLoading && !commits.length" class="muted">loading log…</p>
273
- <div v-else-if="commits.length" class="commits">
274
- <div v-for="c in commits" :key="c.hash" class="commit-row">
275
- <code class="hash">{{ c.hash }}</code>
276
- <span v-if="c.refs" class="refs" :title="c.refs">{{ c.refs.split(",")[0] }}</span>
277
- <span class="subject">{{ c.subject }}</span>
278
- <span class="muted who">{{ c.author }} · {{ c.date }}</span>
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>
279
832
  </div>
280
833
  </div>
281
- <p v-else class="muted">no commits</p>
834
+ <p v-if="logLoading && !commits.length" class="hint">loading log…</p>
835
+ <div v-else-if="commits.length" class="commits">
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>
860
+ </div>
861
+ <p v-else-if="logError" class="message-error">log failed: {{ logError }}</p>
862
+ <p v-else class="hint">no commits</p>
282
863
  </section>
283
864
 
284
- <!-- worktrees: local + linked, one click to a session -->
285
- <section class="wts">
286
- <div class="wt-head">
287
- <strong>Worktrees</strong>
288
- <button :disabled="!!busy" @click="wtMenu = !wtMenu">+ worktree</button>
289
- <div v-if="wtMenu" class="menu">
290
- <button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
291
- <span>{{ b.name }}</span>
292
- <span class="muted">existing branch</span>
293
- </button>
294
- <div class="new-branch">
295
- <input v-model="newBranch" placeholder="or a NEW branch name…" @keyup.enter="addWorktree(newBranch.trim() || 'wt')" />
296
- <button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
865
+ <!-- WORKTREES -->
866
+ <section class="task-section" aria-label="Worktrees">
867
+ <div class="panel-heading">
868
+ <h4><PackageOpen :size="16" /> Worktrees</h4>
869
+ <div class="branch-wrap">
870
+ <button class="small" :disabled="!!busy" @click="wtMenu = !wtMenu"><Plus :size="13" /> worktree</button>
871
+ <div v-if="wtMenu" class="menu">
872
+ <button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
873
+ <GitBranch :size="13" /><span>{{ b.name }}</span><span class="muted-line">existing</span>
874
+ </button>
875
+ <div class="new-branch">
876
+ <input v-model="newBranch" placeholder="or a NEW branch…" @keyup.enter="newBranch.trim() && addWorktree(newBranch.trim())" />
877
+ <button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
878
+ </div>
297
879
  </div>
298
880
  </div>
299
881
  </div>
300
882
  <div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
301
883
  <code :title="w.path">{{ w.path }}</code>
302
- <span class="muted">{{ w.branch ?? w.head }}</span>
884
+ <span class="muted-line">{{ w.branch ?? w.head }}</span>
303
885
  <span class="grow" />
304
- <button :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
305
- <button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="12" /></button>
306
- <Check v-if="w.path === status.root" :size="12" class="muted" />
886
+ <button class="icon-button" :disabled="!!busy" title="jump to this worktree (session or new)" @click="goToWorktree(w.path)"><ArrowRight :size="13" /></button>
887
+ <button class="small" :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
888
+ <button v-if="w.path !== status.root" class="icon-button" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="13" /></button>
889
+ <Check v-if="w.path === status.root" :size="13" class="muted-line" />
307
890
  </div>
308
891
  </section>
309
892
 
310
- <pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
893
+ <pre v-if="out" class="op-out">{{ out.slice(0, 6000) }}</pre>
311
894
  </template>
312
895
  </div>
313
896
  </template>
@@ -315,49 +898,65 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
315
898
  </template>
316
899
 
317
900
  <style scoped>
318
- .git-tab { display: grid; gap: 10px; font-size: 12px; padding: 8px 4px; }
319
- .git-head { display: flex; align-items: center; gap: 8px; }
320
- .git-head .cwd { font-size: 11px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
321
- .branch-bar { position: relative; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
322
- .branch-btn { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; font-size: 12px; }
901
+ .git-pane { display: grid; gap: 4px; }
902
+ .git-top { gap: 8px; flex-wrap: wrap; }
903
+ .branch-wrap { position: relative; }
904
+ .branch-btn { display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border: 1px solid var(--line); border-radius: 6px; }
905
+ .branch-btn strong { font-size: 16px; }
323
906
  .menu {
324
- position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 260px; max-height: 260px; overflow: auto;
325
- background: var(--bg-2, #1c1c22); border: 1px solid var(--border, #333); border-radius: 8px; padding: 4px;
326
- box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
907
+ position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 300px; max-height: 280px; overflow: auto;
908
+ background: var(--bg-2, #221a1c); border: 1px solid var(--line); border-radius: 8px; padding: 4px;
909
+ box-shadow: 0 10px 30px rgb(0 0 0 / 50%);
327
910
  }
328
- .menu button { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 5px 8px; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; }
329
- .menu button:hover, .menu button.cur { background: rgb(127 127 127 / 15%); }
330
- .menu .pad { padding: 6px 8px; margin: 0; }
331
- .row { display: flex; gap: 6px; }
332
- .ops { flex-wrap: wrap; }
333
- .sec-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 2px; border-bottom: 1px solid var(--border, #2a2a30); }
334
- .commit { display: grid; gap: 6px; }
335
- .logsec { display: grid; gap: 6px; }
336
- .commits { max-height: 320px; overflow: auto; display: grid; gap: 1px; }
337
- .commit-row { display: flex; gap: 6px; align-items: baseline; white-space: nowrap; }
338
- .commit-row .hash { color: var(--ok, #2ea043); font-size: 11px; }
339
- .commit-row .refs { color: var(--warn, #e5a50a); font-size: 10px; max-width: 16ch; overflow: hidden; text-overflow: ellipsis; }
340
- .commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 40%; }
341
- .commit-row .who { font-size: 10px; }
342
- .files { max-height: 180px; overflow: auto; border: 1px solid var(--border, #2a2a30); border-radius: 6px; padding: 2px 6px; }
343
- .file { display: flex; gap: 6px; align-items: center; padding: 2px 0; }
344
- .file.all { border-bottom: 1px solid var(--border, #2a2a30); }
345
- .file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
346
- .xy { opacity: 0.7; font-size: 10px; }
347
- textarea { width: 100%; resize: vertical; min-height: 64px; font: inherit; }
348
- button.primary { justify-self: start; }
349
- .wts { display: grid; gap: 4px; }
350
- .wt-head { position: relative; display: flex; align-items: center; gap: 8px; }
351
- .wt { display: flex; gap: 8px; align-items: center; }
352
- .wt code { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46%; }
353
- .wt.main code { font-weight: 600; }
911
+ .menu button { display: flex; gap: 10px; align-items: center; width: 100%; padding: 7px 10px; border-radius: 6px; text-align: left; font-size: 15px; }
912
+ .menu button:hover, .menu button.cur { background: #403536; }
913
+ .menu .new-branch { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
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; }
916
+ .muted-line { opacity: 0.65; font-size: 13px; }
917
+ .cap { padding: 4px 0; }
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; }
923
+ .file { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 3px 0; }
924
+ .file.all { border-bottom: 1px solid var(--line); }
925
+ .file-label { display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0; cursor: pointer; }
926
+ .file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
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); }
932
+ .file-acts { display: flex; gap: 2px; }
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; }
938
+ textarea { width: 100%; resize: vertical; min-height: 76px; font: inherit; margin-top: 8px; }
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; }
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); }
945
+ .commit-row .refs { color: var(--warn, #e5a50a); font-size: 12px; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
946
+ .commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 40%; font-size: 14px; }
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%; }
955
+ .wt { display: flex; gap: 8px; align-items: center; padding: 2px 0; }
956
+ .wt code { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
957
+ .wt.main code { font-weight: 650; }
354
958
  .grow { flex: 1; }
355
- .new-branch { display: flex; gap: 6px; padding: 6px 8px; border-top: 1px solid var(--border, #2a2a30); }
356
- .new-branch input { flex: 1; min-width: 0; }
357
- .muted { opacity: 0.65; }
358
- .note { font-size: 11px; }
359
- .stale { opacity: 0.55; transition: opacity 0.2s; }
360
- .out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
361
- .empty { opacity: 0.75; }
362
- .error { color: var(--danger, #f85149); }
959
+ .tag-x { margin-left: 4px; opacity: 0.7; display: inline-flex; }
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; }
961
+ h4 { display: flex; align-items: center; gap: 8px; font-size: 16px; margin: 0; }
363
962
  </style>