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.
- package/apps/dpi-backend/src/git.ts +176 -17
- package/apps/dpi-backend/src/routes.ts +11 -1
- package/apps/dpi-backend/src/store.ts +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-C9OQe54o.js +79 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DwYK0DEy.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitPanel.vue +559 -57
- package/apps/dpi-sdk/src/client.ts +43 -8
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CsBzwiFM.js +0 -78
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DM_ddsmz.css +0 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
320
|
-
<
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
<
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
<
|
|
363
|
-
<
|
|
364
|
-
|
|
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 & 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
|
-
<
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
.
|
|
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:
|
|
449
|
-
.commit-row { display: flex; gap: 8px; align-items:
|
|
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:
|
|
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:
|
|
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>
|