my-durable 0.0.23 → 0.0.25
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 +57 -5
- package/apps/dpi-backend/src/routes.ts +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CsBzwiFM.js +78 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DM_ddsmz.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 +269 -119
- package/apps/dpi-sdk/src/client.ts +14 -3
- package/docs/git-ui-checklist.md +194 -0
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BT2MDDdt.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CclVXMo8.js +0 -78
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Inspector GIT tab,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Lazy: rides the shared git-store (one fetch per folder while fresh), the
|
|
7
|
-
// refresh button and every op are the only forced refetches.
|
|
2
|
+
// Inspector GIT tab — the IDE git window, in the app's own design language
|
|
3
|
+
// (panel-heading + tags + task-sections). IntelliJ features: branch popup,
|
|
4
|
+
// changelist commit with per-file diff/stage/discard, structured log,
|
|
5
|
+
// push with ahead count, stash, worktree sessions.
|
|
8
6
|
import { computed, onServerPrefetch, ref, watch } from "vue";
|
|
9
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ArrowDownToLine,
|
|
9
|
+
ArrowUpFromLine,
|
|
10
|
+
Check,
|
|
11
|
+
ChevronDown,
|
|
12
|
+
CornerUpLeft,
|
|
13
|
+
Eye,
|
|
14
|
+
GitBranch,
|
|
15
|
+
History,
|
|
16
|
+
PackageOpen,
|
|
17
|
+
Plus,
|
|
18
|
+
RefreshCw,
|
|
19
|
+
SquareStack,
|
|
20
|
+
X,
|
|
21
|
+
} from "lucide-vue-next";
|
|
10
22
|
import { deviceId, network, sessionSummary, notify, select } from "../client/store";
|
|
11
23
|
import { deviceClient } from "../client/hub";
|
|
12
24
|
import { ensureGit, gitEntry } from "../client/git-store";
|
|
@@ -29,21 +41,23 @@ const checked = ref<Set<string>>(new Set());
|
|
|
29
41
|
const branchMenu = ref(false);
|
|
30
42
|
const wtMenu = ref(false);
|
|
31
43
|
const newBranch = ref("");
|
|
44
|
+
/** IntelliJ: click a changed file → its diff, right there. */
|
|
45
|
+
const fileDiff = ref<{ path: string; text: string } | null>(null);
|
|
32
46
|
|
|
33
47
|
if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
|
|
34
48
|
onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
|
|
35
49
|
watch([devId, cwd], () => {
|
|
36
50
|
branchMenu.value = false;
|
|
37
51
|
wtMenu.value = false;
|
|
52
|
+
fileDiff.value = null;
|
|
38
53
|
void ensureGit(devId.value || "?", cwd.value || "?");
|
|
39
54
|
});
|
|
40
55
|
const refresh = () => {
|
|
41
56
|
checked.value = new Set();
|
|
57
|
+
fileDiff.value = null;
|
|
42
58
|
return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
|
|
43
59
|
};
|
|
44
60
|
|
|
45
|
-
// changed files: staged+modified default-checked, untracked not — IntelliJ's
|
|
46
|
-
// changelist behavior.
|
|
47
61
|
const files = computed(() => status.value?.files ?? []);
|
|
48
62
|
watch(files, (list) => {
|
|
49
63
|
const next = new Set<string>();
|
|
@@ -51,6 +65,10 @@ watch(files, (list) => {
|
|
|
51
65
|
checked.value = next;
|
|
52
66
|
});
|
|
53
67
|
const someChecked = computed(() => files.value.some((f) => checked.value.has(f.path)));
|
|
68
|
+
/** orca's split: staged (index changed) vs unstaged (worktree + untracked). */
|
|
69
|
+
const stagedFiles = computed(() => files.value.filter((f) => f.x !== " " && f.x !== "?"));
|
|
70
|
+
const unstagedFiles = computed(() => files.value.filter((f) => f.x === " " || f.x === "?"));
|
|
71
|
+
const atCap = computed(() => files.value.length >= 500);
|
|
54
72
|
const toggle = (p: string) => {
|
|
55
73
|
const next = new Set(checked.value);
|
|
56
74
|
if (next.has(p)) next.delete(p);
|
|
@@ -60,31 +78,73 @@ const toggle = (p: string) => {
|
|
|
60
78
|
const toggleAll = () => {
|
|
61
79
|
checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
|
|
62
80
|
};
|
|
81
|
+
const toggleGroup = (group: { path: string }[]) => {
|
|
82
|
+
const all = group.every((f) => checked.value.has(f.path));
|
|
83
|
+
const next = new Set(checked.value);
|
|
84
|
+
for (const f of group) {
|
|
85
|
+
if (all) next.delete(f.path);
|
|
86
|
+
else next.add(f.path);
|
|
87
|
+
}
|
|
88
|
+
checked.value = next;
|
|
89
|
+
};
|
|
63
90
|
|
|
64
|
-
async function op(name: "fetch" | "pull" | "
|
|
91
|
+
async function op(name: "fetch" | "pull" | "push" | "branch" | "diff" | "stash-push" | "stash-pop", extra: { message?: string } = {}) {
|
|
65
92
|
if (!devId.value || !cwd.value) return;
|
|
66
93
|
busy.value = name;
|
|
67
94
|
out.value = "";
|
|
68
95
|
try {
|
|
69
|
-
const r = await deviceClient(devId.value).gitOp(cwd.value, name);
|
|
96
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, name, extra);
|
|
70
97
|
out.value = r.text || "(empty)";
|
|
71
|
-
if (
|
|
98
|
+
if (["fetch", "pull", "push", "stash-push", "stash-pop"].includes(name)) notify(`git ${name} ✓`);
|
|
99
|
+
await refresh();
|
|
100
|
+
void loadLog(true);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
notify(`git ${name}: ${(e as Error).message.slice(0, 160)}`);
|
|
103
|
+
} finally {
|
|
104
|
+
busy.value = "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fileAction(name: "stage" | "discard", path?: string) {
|
|
109
|
+
if (!devId.value || !cwd.value) return;
|
|
110
|
+
if (!path && name !== "stage") return;
|
|
111
|
+
if (name === "discard" && path && !confirm(`Roll back ${path} to HEAD? Uncommitted changes are lost.`)) return;
|
|
112
|
+
busy.value = name;
|
|
113
|
+
const targets = path ? [path] : files.value.filter((f) => f.x === " " || f.x === "M").map((f) => f.path);
|
|
114
|
+
try {
|
|
115
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, name, { files: targets });
|
|
116
|
+
notify(r.text);
|
|
72
117
|
await refresh();
|
|
73
118
|
} catch (e) {
|
|
74
|
-
|
|
119
|
+
notify(`${name} failed: ${(e as Error).message.slice(0, 140)}`);
|
|
75
120
|
} finally {
|
|
76
121
|
busy.value = "";
|
|
77
122
|
}
|
|
78
123
|
}
|
|
79
124
|
|
|
80
|
-
async function
|
|
125
|
+
async function showDiff(path: string) {
|
|
126
|
+
if (!devId.value || !cwd.value) return;
|
|
127
|
+
if (fileDiff.value?.path === path) return (fileDiff.value = null);
|
|
128
|
+
busy.value = "diff";
|
|
129
|
+
try {
|
|
130
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "diff-file", { target: path });
|
|
131
|
+
fileDiff.value = { path, text: r.text };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
notify(`diff failed: ${(e as Error).message.slice(0, 140)}`);
|
|
134
|
+
} finally {
|
|
135
|
+
busy.value = "";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function switchBranch(name: string, create = false) {
|
|
81
140
|
branchMenu.value = false;
|
|
82
|
-
if (!devId.value || !cwd.value || name === status.value?.branch) return;
|
|
141
|
+
if (!devId.value || !cwd.value || (name === status.value?.branch && !create)) return;
|
|
83
142
|
busy.value = "switch";
|
|
84
143
|
try {
|
|
85
|
-
const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name });
|
|
144
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name, create });
|
|
86
145
|
notify(r.text || `switched to ${name}`);
|
|
87
146
|
await refresh();
|
|
147
|
+
void loadLog(true);
|
|
88
148
|
} catch (e) {
|
|
89
149
|
notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
|
|
90
150
|
} finally {
|
|
@@ -96,14 +156,16 @@ async function commit() {
|
|
|
96
156
|
if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
|
|
97
157
|
busy.value = "commit";
|
|
98
158
|
const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
|
|
159
|
+
if (!chosen.length) return notify("check the files to commit — nothing is selected");
|
|
99
160
|
try {
|
|
100
161
|
const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
|
|
101
162
|
message: commitMsg.value.trim(),
|
|
102
|
-
|
|
163
|
+
files: chosen,
|
|
103
164
|
});
|
|
104
165
|
notify(r.text);
|
|
105
166
|
commitMsg.value = "";
|
|
106
167
|
await refresh();
|
|
168
|
+
void loadLog(true);
|
|
107
169
|
} catch (e) {
|
|
108
170
|
notify(`commit failed: ${(e as Error).message.slice(0, 140)}`);
|
|
109
171
|
} finally {
|
|
@@ -120,10 +182,7 @@ async function addWorktree(branch: string) {
|
|
|
120
182
|
const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
|
|
121
183
|
busy.value = "wt";
|
|
122
184
|
try {
|
|
123
|
-
const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", {
|
|
124
|
-
branch: branch, // existing branch checks out; new name creates it
|
|
125
|
-
target,
|
|
126
|
-
});
|
|
185
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", { branch, target });
|
|
127
186
|
notify(r.text.split("\n")[0] ?? `worktree ${target}`);
|
|
128
187
|
newBranch.value = "";
|
|
129
188
|
await refresh();
|
|
@@ -134,14 +193,12 @@ async function addWorktree(branch: string) {
|
|
|
134
193
|
}
|
|
135
194
|
}
|
|
136
195
|
|
|
137
|
-
/** A worktree is a working copy — one click and it is a SESSION (herd flow). */
|
|
138
196
|
async function sessionOn(path: string, branch: string | null) {
|
|
139
197
|
if (!devId.value) return;
|
|
140
198
|
busy.value = "session";
|
|
141
199
|
try {
|
|
142
200
|
const info = network.devices.value.find((d) => d.id === devId.value)?.info;
|
|
143
|
-
const model =
|
|
144
|
-
(info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
|
|
201
|
+
const model = (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
|
|
145
202
|
if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
|
|
146
203
|
const s = await deviceClient(devId.value).createSession({
|
|
147
204
|
name: branch ?? path.slice(path.lastIndexOf("/") + 1),
|
|
@@ -170,99 +227,190 @@ async function removeWorktree(path: string) {
|
|
|
170
227
|
busy.value = "";
|
|
171
228
|
}
|
|
172
229
|
}
|
|
230
|
+
|
|
231
|
+
const commits = ref<{ hash: string; refs: string; author: string; date: string; subject: string }[]>([]);
|
|
232
|
+
const logLoading = ref(false);
|
|
233
|
+
const logError = ref("");
|
|
234
|
+
let logAt = 0;
|
|
235
|
+
async function loadLog(force = false) {
|
|
236
|
+
if (!devId.value || !cwd.value) return;
|
|
237
|
+
if (!force && commits.value.length && Date.now() - logAt < 120_000) return;
|
|
238
|
+
logLoading.value = true;
|
|
239
|
+
try {
|
|
240
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "log-list");
|
|
241
|
+
commits.value = r.commits ?? [];
|
|
242
|
+
logError.value = "";
|
|
243
|
+
logAt = Date.now();
|
|
244
|
+
} catch (e) {
|
|
245
|
+
// "no commits" lied when the fetch failed (checklist Wave 1a) — say it.
|
|
246
|
+
logError.value = (e as Error).message.slice(0, 120);
|
|
247
|
+
} finally {
|
|
248
|
+
logLoading.value = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!import.meta.env.SSR) void loadLog();
|
|
252
|
+
watch([devId, cwd], () => {
|
|
253
|
+
commits.value = [];
|
|
254
|
+
logAt = 0;
|
|
255
|
+
void loadLog();
|
|
256
|
+
});
|
|
173
257
|
const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
|
|
174
258
|
</script>
|
|
175
259
|
|
|
176
260
|
<template>
|
|
177
|
-
<div class="git-
|
|
178
|
-
<p v-if="!cwd" class="
|
|
261
|
+
<div class="git-pane">
|
|
262
|
+
<p v-if="!cwd" class="hint">No session selected — open a session to see its git.</p>
|
|
179
263
|
<template v-else>
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<button :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
184
|
-
</header>
|
|
185
|
-
|
|
186
|
-
<p v-if="loading" class="empty">loading git…</p>
|
|
187
|
-
<p v-else-if="refreshing" class="muted note">refreshing…</p>
|
|
188
|
-
<p v-if="error" class="error">{{ error }} <button :disabled="!!busy" @click="refresh()">retry</button></p>
|
|
264
|
+
<p v-if="loading" class="hint">loading git…</p>
|
|
265
|
+
<p v-else-if="refreshing" class="muted-line">refreshing…</p>
|
|
266
|
+
<p v-if="error" class="message-error">{{ error }} <button @click="refresh()">retry</button></p>
|
|
189
267
|
|
|
190
268
|
<div v-if="status" :class="{ stale: !!error || !!refreshing }">
|
|
191
|
-
<p v-if="!status.repo" class="
|
|
269
|
+
<p v-if="!status.repo" class="hint">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
|
|
192
270
|
|
|
193
271
|
<template v-else>
|
|
194
|
-
<!-- branch
|
|
195
|
-
<div class="
|
|
196
|
-
<
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
<ChevronDown :size="14" />
|
|
201
|
-
</button>
|
|
202
|
-
<span class="muted">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }} · {{ status.head }}</span>
|
|
203
|
-
<div v-if="branchMenu" class="menu">
|
|
204
|
-
<button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
|
|
205
|
-
<span>{{ b.name }}</span>
|
|
206
|
-
<span class="muted">{{ b.current ? "● current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
|
|
272
|
+
<!-- branch popup + sync, the IDE way -->
|
|
273
|
+
<div class="panel-heading git-top">
|
|
274
|
+
<div class="branch-wrap">
|
|
275
|
+
<button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
|
|
276
|
+
<GitBranch :size="16" /><strong>{{ status.branch ?? "detached" }}</strong>
|
|
277
|
+
<ChevronDown :size="14" />
|
|
207
278
|
</button>
|
|
208
|
-
<
|
|
279
|
+
<div v-if="branchMenu" class="menu">
|
|
280
|
+
<button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
|
|
281
|
+
<GitBranch :size="13" /><span>{{ b.name }}</span>
|
|
282
|
+
<span class="muted-line">{{ b.current ? "current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
|
|
283
|
+
</button>
|
|
284
|
+
<div class="new-branch">
|
|
285
|
+
<input v-model="newBranch" placeholder="new branch…" @keyup.enter="newBranch.trim() && switchBranch(newBranch.trim(), true)" />
|
|
286
|
+
<button :disabled="!newBranch.trim()" @click="switchBranch(newBranch.trim(), true)"><Plus :size="13" /></button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
209
289
|
</div>
|
|
290
|
+
<span class="tag">{{ status.upstream ?? "no upstream" }}</span>
|
|
291
|
+
<span v-if="status.ahead" class="tag">↑{{ status.ahead }}</span>
|
|
292
|
+
<span v-if="status.behind" class="tag">↓{{ status.behind }}</span>
|
|
293
|
+
<span class="tag">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }}</span>
|
|
294
|
+
<button class="icon-button" :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="16" /></button>
|
|
210
295
|
</div>
|
|
211
296
|
|
|
212
297
|
<div class="row ops">
|
|
213
|
-
<button :disabled="!!busy" @click="op('fetch')"
|
|
214
|
-
<button :disabled="!!busy" @click="op('pull')"
|
|
215
|
-
<button :disabled="!!busy" @click="op('
|
|
216
|
-
<button :disabled="!!busy" @click="op('
|
|
217
|
-
<button :disabled="!!busy" @click="op('
|
|
298
|
+
<button class="small" :disabled="!!busy" @click="op('fetch')"><ArrowDownToLine :size="14" />fetch</button>
|
|
299
|
+
<button class="small" :disabled="!!busy" @click="op('pull')"><ArrowDownToLine :size="14" />pull</button>
|
|
300
|
+
<button class="small primary" :disabled="!!busy" title="push" @click="op('push')"><ArrowUpFromLine :size="14" />push</button>
|
|
301
|
+
<button class="small" :disabled="!!busy" title="stash changes" @click="op('stash-push', { message: commitMsg.trim() || undefined })"><SquareStack :size="14" />stash</button>
|
|
302
|
+
<button class="small" :disabled="!!busy" title="pop the newest stash" @click="op('stash-pop')"><PackageOpen :size="14" />unstash</button>
|
|
218
303
|
</div>
|
|
219
304
|
|
|
220
|
-
<!--
|
|
221
|
-
<section
|
|
222
|
-
<div class="
|
|
305
|
+
<!-- COMMIT — always present, changelist semantics -->
|
|
306
|
+
<section class="task-section" aria-label="Commit">
|
|
307
|
+
<div class="panel-heading">
|
|
308
|
+
<h4>Commit</h4>
|
|
309
|
+
<span class="tag">{{ files.length ? `${checked.size}/${files.length} files` : "no changes" }}</span>
|
|
310
|
+
</div>
|
|
311
|
+
<p v-if="atCap" class="muted-line cap">showing the first 500 changed files — commit in smaller batches</p>
|
|
312
|
+
<div v-if="stagedFiles.length" class="files">
|
|
223
313
|
<label class="file all">
|
|
224
|
-
<input type="checkbox" :checked="
|
|
225
|
-
<span class="muted">
|
|
314
|
+
<input type="checkbox" :checked="stagedFiles.every((f) => checked.has(f.path))" @change="toggleGroup(stagedFiles)" />
|
|
315
|
+
<span class="muted-line">Staged · {{ stagedFiles.length }}</span>
|
|
316
|
+
<span class="grow" />
|
|
317
|
+
<button class="icon-button" title="stage every changed file" @click="fileAction('stage')"><Plus :size="13" /> all</button>
|
|
226
318
|
</label>
|
|
227
|
-
<
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
319
|
+
<div v-for="f in stagedFiles" :key="f.path" class="file">
|
|
320
|
+
<label class="file-label">
|
|
321
|
+
<input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
|
|
322
|
+
<code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
|
|
323
|
+
<span class="path" :title="f.path">{{ f.path }}</span>
|
|
324
|
+
</label>
|
|
325
|
+
<span class="file-acts">
|
|
326
|
+
<button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
|
|
327
|
+
<button class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
|
|
328
|
+
</span>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div v-if="unstagedFiles.length" class="files">
|
|
332
|
+
<label class="file all">
|
|
333
|
+
<input type="checkbox" :checked="unstagedFiles.every((f) => checked.has(f.path))" @change="toggleGroup(unstagedFiles)" />
|
|
334
|
+
<span class="muted-line">Unstaged · {{ unstagedFiles.length }}</span>
|
|
231
335
|
</label>
|
|
336
|
+
<div v-for="f in unstagedFiles" :key="f.path" class="file">
|
|
337
|
+
<label class="file-label">
|
|
338
|
+
<input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
|
|
339
|
+
<code class="xy" :title="`index ${f.x} · worktree ${f.y}`">{{ f.x }}{{ f.y }}</code>
|
|
340
|
+
<span class="path" :title="f.path">{{ f.path }}</span>
|
|
341
|
+
</label>
|
|
342
|
+
<span class="file-acts">
|
|
343
|
+
<button class="icon-button" title="view diff" @click="showDiff(f.path)"><Eye :size="13" /></button>
|
|
344
|
+
<button class="icon-button" title="stage (git add)" @click="fileAction('stage', f.path)"><Plus :size="13" /></button>
|
|
345
|
+
<button class="icon-button" title="roll back to HEAD" @click="fileAction('discard', f.path)"><CornerUpLeft :size="13" /></button>
|
|
346
|
+
</span>
|
|
347
|
+
</div>
|
|
232
348
|
</div>
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
349
|
+
<p v-if="fileDiff" class="file-diff">
|
|
350
|
+
<strong>{{ fileDiff.path }}</strong>
|
|
351
|
+
<button class="icon-button" title="close diff" @click="fileDiff = null"><X :size="13" /></button>
|
|
352
|
+
<pre>{{ fileDiff.text.slice(0, 8000) }}</pre>
|
|
353
|
+
</p>
|
|
354
|
+
<textarea
|
|
355
|
+
v-model="commitMsg"
|
|
356
|
+
rows="4"
|
|
357
|
+
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"
|
|
361
|
+
/>
|
|
362
|
+
<button class="primary small" :disabled="!!busy || !commitMsg.trim() || !someChecked" @click="commit">
|
|
363
|
+
<Check :size="14" /> Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : "" }}
|
|
236
364
|
</button>
|
|
237
365
|
</section>
|
|
238
366
|
|
|
239
|
-
<!--
|
|
240
|
-
<section class="
|
|
241
|
-
<div class="
|
|
242
|
-
<
|
|
243
|
-
<button :disabled="
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
</
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
367
|
+
<!-- LOG -->
|
|
368
|
+
<section class="task-section" aria-label="Log">
|
|
369
|
+
<div class="panel-heading">
|
|
370
|
+
<h4><History :size="16" /> Log</h4>
|
|
371
|
+
<button class="icon-button" :disabled="logLoading" title="refresh log" @click="loadLog(true)"><RefreshCw :size="14" /></button>
|
|
372
|
+
</div>
|
|
373
|
+
<p v-if="logLoading && !commits.length" class="hint">loading log…</p>
|
|
374
|
+
<div v-else-if="commits.length" class="commits">
|
|
375
|
+
<div v-for="c in commits" :key="c.hash" class="commit-row" :title="c.refs">
|
|
376
|
+
<code class="hash">{{ c.hash }}</code>
|
|
377
|
+
<span v-if="c.refs" class="refs">{{ c.refs.split(",")[0] }}</span>
|
|
378
|
+
<span class="subject">{{ c.subject }}</span>
|
|
379
|
+
<span class="muted-line who">{{ c.author }} · {{ c.date }}</span>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
<p v-else-if="logError" class="message-error">log failed: {{ logError }}</p>
|
|
383
|
+
<p v-else class="hint">no commits</p>
|
|
384
|
+
</section>
|
|
385
|
+
|
|
386
|
+
<!-- WORKTREES -->
|
|
387
|
+
<section class="task-section" aria-label="Worktrees">
|
|
388
|
+
<div class="panel-heading">
|
|
389
|
+
<h4><PackageOpen :size="16" /> Worktrees</h4>
|
|
390
|
+
<div class="branch-wrap">
|
|
391
|
+
<button class="small" :disabled="!!busy" @click="wtMenu = !wtMenu"><Plus :size="13" /> worktree</button>
|
|
392
|
+
<div v-if="wtMenu" class="menu">
|
|
393
|
+
<button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
|
|
394
|
+
<GitBranch :size="13" /><span>{{ b.name }}</span><span class="muted-line">existing</span>
|
|
395
|
+
</button>
|
|
396
|
+
<div class="new-branch">
|
|
397
|
+
<input v-model="newBranch" placeholder="or a NEW branch…" @keyup.enter="newBranch.trim() && addWorktree(newBranch.trim())" />
|
|
398
|
+
<button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
|
|
399
|
+
</div>
|
|
252
400
|
</div>
|
|
253
401
|
</div>
|
|
254
402
|
</div>
|
|
255
403
|
<div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
|
|
256
404
|
<code :title="w.path">{{ w.path }}</code>
|
|
257
|
-
<span class="muted">{{ w.branch ?? w.head }}</span>
|
|
405
|
+
<span class="muted-line">{{ w.branch ?? w.head }}</span>
|
|
258
406
|
<span class="grow" />
|
|
259
|
-
<button :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
|
|
260
|
-
<button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="
|
|
261
|
-
<Check v-if="w.path === status.root" :size="
|
|
407
|
+
<button class="small" :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
|
|
408
|
+
<button v-if="w.path !== status.root" class="icon-button" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="13" /></button>
|
|
409
|
+
<Check v-if="w.path === status.root" :size="13" class="muted-line" />
|
|
262
410
|
</div>
|
|
263
411
|
</section>
|
|
264
412
|
|
|
265
|
-
<pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
|
|
413
|
+
<pre v-if="out" class="op-out">{{ out.slice(0, 6000) }}</pre>
|
|
266
414
|
</template>
|
|
267
415
|
</div>
|
|
268
416
|
</template>
|
|
@@ -270,41 +418,43 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
|
|
|
270
418
|
</template>
|
|
271
419
|
|
|
272
420
|
<style scoped>
|
|
273
|
-
.git-
|
|
274
|
-
.git-
|
|
275
|
-
.
|
|
276
|
-
.branch-
|
|
277
|
-
.branch-btn {
|
|
421
|
+
.git-pane { display: grid; gap: 4px; }
|
|
422
|
+
.git-top { gap: 8px; flex-wrap: wrap; }
|
|
423
|
+
.branch-wrap { position: relative; }
|
|
424
|
+
.branch-btn { display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border: 1px solid var(--line); border-radius: 6px; }
|
|
425
|
+
.branch-btn strong { font-size: 16px; }
|
|
278
426
|
.menu {
|
|
279
|
-
position: absolute; top: calc(100% +
|
|
280
|
-
background: var(--bg-2, #
|
|
281
|
-
box-shadow: 0
|
|
427
|
+
position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 300px; max-height: 280px; overflow: auto;
|
|
428
|
+
background: var(--bg-2, #221a1c); border: 1px solid var(--line); border-radius: 8px; padding: 4px;
|
|
429
|
+
box-shadow: 0 10px 30px rgb(0 0 0 / 50%);
|
|
282
430
|
}
|
|
283
|
-
.menu button { display: flex;
|
|
284
|
-
.menu button:hover, .menu button.cur { background:
|
|
285
|
-
.menu .
|
|
286
|
-
.
|
|
287
|
-
.
|
|
288
|
-
.
|
|
289
|
-
.
|
|
290
|
-
.
|
|
291
|
-
.file
|
|
292
|
-
.file
|
|
293
|
-
.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.
|
|
297
|
-
.
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
.
|
|
431
|
+
.menu button { display: flex; gap: 10px; align-items: center; width: 100%; padding: 7px 10px; border-radius: 6px; text-align: left; font-size: 15px; }
|
|
432
|
+
.menu button:hover, .menu button.cur { background: #403536; }
|
|
433
|
+
.menu .new-branch { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
|
|
434
|
+
.menu .new-branch input { flex: 1; min-width: 0; }
|
|
435
|
+
.muted-line { opacity: 0.65; font-size: 13px; }
|
|
436
|
+
.cap { padding: 4px 0; }
|
|
437
|
+
.row { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
|
|
438
|
+
.files { max-height: 220px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; }
|
|
439
|
+
.file { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 3px 0; }
|
|
440
|
+
.file.all { border-bottom: 1px solid var(--line); }
|
|
441
|
+
.file-label { display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0; cursor: pointer; }
|
|
442
|
+
.file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
|
443
|
+
.file .xy { opacity: 0.7; font-size: 12px; }
|
|
444
|
+
.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; }
|
|
447
|
+
textarea { width: 100%; resize: vertical; min-height: 76px; font: inherit; margin-top: 8px; }
|
|
448
|
+
.commits { max-height: 320px; overflow: auto; display: grid; gap: 3px; }
|
|
449
|
+
.commit-row { display: flex; gap: 8px; align-items: baseline; white-space: nowrap; }
|
|
450
|
+
.commit-row .hash { color: var(--ok, #2ea043); font-size: 13px; }
|
|
451
|
+
.commit-row .refs { color: var(--warn, #e5a50a); font-size: 12px; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
|
|
452
|
+
.commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 42%; font-size: 14px; }
|
|
453
|
+
.commit-row .who { font-size: 12px; }
|
|
454
|
+
.wt { display: flex; gap: 8px; align-items: center; padding: 2px 0; }
|
|
455
|
+
.wt code { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44%; }
|
|
456
|
+
.wt.main code { font-weight: 650; }
|
|
301
457
|
.grow { flex: 1; }
|
|
302
|
-
.
|
|
303
|
-
|
|
304
|
-
.muted { opacity: 0.65; }
|
|
305
|
-
.note { font-size: 11px; }
|
|
306
|
-
.stale { opacity: 0.55; transition: opacity 0.2s; }
|
|
307
|
-
.out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
|
|
308
|
-
.empty { opacity: 0.75; }
|
|
309
|
-
.error { color: var(--danger, #f85149); }
|
|
458
|
+
.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
|
+
h4 { display: flex; align-items: center; gap: 8px; font-size: 16px; margin: 0; }
|
|
310
460
|
</style>
|
|
@@ -59,6 +59,7 @@ export interface GitStatus {
|
|
|
59
59
|
export type GitOpName =
|
|
60
60
|
| "diff"
|
|
61
61
|
| "log"
|
|
62
|
+
| "log-list"
|
|
62
63
|
| "fetch"
|
|
63
64
|
| "pull"
|
|
64
65
|
| "switch"
|
|
@@ -66,7 +67,13 @@ export type GitOpName =
|
|
|
66
67
|
| "commit"
|
|
67
68
|
| "worktree-list"
|
|
68
69
|
| "worktree-add"
|
|
69
|
-
| "worktree-remove"
|
|
70
|
+
| "worktree-remove"
|
|
71
|
+
| "push"
|
|
72
|
+
| "stage"
|
|
73
|
+
| "discard"
|
|
74
|
+
| "diff-file"
|
|
75
|
+
| "stash-push"
|
|
76
|
+
| "stash-pop";
|
|
70
77
|
|
|
71
78
|
export class DpiClient {
|
|
72
79
|
readonly baseUrl: string;
|
|
@@ -180,9 +187,13 @@ export class DpiClient {
|
|
|
180
187
|
gitOp(
|
|
181
188
|
path: string,
|
|
182
189
|
op: GitOpName,
|
|
183
|
-
opts: { ref?: string; branch?: string; message?: string; target?: string; files?: string[] } = {},
|
|
190
|
+
opts: { ref?: string; branch?: string; message?: string; target?: string; files?: string[]; create?: boolean; staged?: boolean } = {},
|
|
184
191
|
) {
|
|
185
|
-
return this.request<{ text: string
|
|
192
|
+
return this.request<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }>(
|
|
193
|
+
"POST",
|
|
194
|
+
"/v1/git",
|
|
195
|
+
{ path, op, ...opts },
|
|
196
|
+
);
|
|
186
197
|
}
|
|
187
198
|
/** Many paths, one round-trip — session lists want branch-per-cwd. */
|
|
188
199
|
gitStatusBatch(paths: string[], options: { timeoutMs?: number } = {}) {
|