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.
- package/apps/dpi-backend/src/git.ts +211 -18
- 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 +769 -170
- package/apps/dpi-sdk/src/client.ts +49 -8
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/docs/git-ui-checklist.md +194 -0
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BLV50tg7.js +0 -78
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CpVvdDil.css +0 -1
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Inspector GIT tab,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
205
|
-
<p v-if="!cwd" class="
|
|
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
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
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="
|
|
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
|
-
<!--
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
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
|
-
<
|
|
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')"
|
|
241
|
-
<button :disabled="!!busy" @click="op('pull')"
|
|
242
|
-
<button :disabled="!!busy" @click="op('
|
|
243
|
-
<button :disabled="!!busy" @click="op('
|
|
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
|
|
247
|
-
<section class="
|
|
248
|
-
<div class="
|
|
249
|
-
|
|
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="
|
|
252
|
-
<span class="muted">
|
|
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
|
-
<
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
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 & 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:
|
|
267
|
-
<section class="
|
|
268
|
-
<div class="
|
|
269
|
-
<
|
|
270
|
-
<
|
|
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
|
-
<
|
|
273
|
-
|
|
274
|
-
<div v-for="
|
|
275
|
-
<code
|
|
276
|
-
<span
|
|
277
|
-
<span class="
|
|
278
|
-
<span class="
|
|
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-
|
|
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
|
-
<!--
|
|
285
|
-
<section class="
|
|
286
|
-
<div class="
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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="
|
|
305
|
-
<button
|
|
306
|
-
<
|
|
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-
|
|
319
|
-
.git-
|
|
320
|
-
.
|
|
321
|
-
.branch-
|
|
322
|
-
.branch-btn {
|
|
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% +
|
|
325
|
-
background: var(--bg-2, #
|
|
326
|
-
box-shadow: 0
|
|
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;
|
|
329
|
-
.menu button:hover, .menu button.cur { background:
|
|
330
|
-
.menu .
|
|
331
|
-
.
|
|
332
|
-
.
|
|
333
|
-
.
|
|
334
|
-
.
|
|
335
|
-
.
|
|
336
|
-
.
|
|
337
|
-
.
|
|
338
|
-
.
|
|
339
|
-
.
|
|
340
|
-
.
|
|
341
|
-
.
|
|
342
|
-
.
|
|
343
|
-
.file {
|
|
344
|
-
.file.
|
|
345
|
-
.
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
.
|
|
350
|
-
.
|
|
351
|
-
.
|
|
352
|
-
.
|
|
353
|
-
.
|
|
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
|
-
.
|
|
356
|
-
.
|
|
357
|
-
|
|
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>
|