my-durable 0.0.24 → 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 +38 -4
- 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 +258 -161
- package/apps/dpi-sdk/src/client.ts +8 -2
- 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,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";
|
|
@@ -24,50 +36,28 @@ const error = computed(() => entry.value.error);
|
|
|
24
36
|
|
|
25
37
|
const busy = ref("");
|
|
26
38
|
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
39
|
const commitMsg = ref("");
|
|
52
40
|
const checked = ref<Set<string>>(new Set());
|
|
53
41
|
const branchMenu = ref(false);
|
|
54
42
|
const wtMenu = ref(false);
|
|
55
43
|
const newBranch = ref("");
|
|
44
|
+
/** IntelliJ: click a changed file → its diff, right there. */
|
|
45
|
+
const fileDiff = ref<{ path: string; text: string } | null>(null);
|
|
56
46
|
|
|
57
47
|
if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
|
|
58
48
|
onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
|
|
59
49
|
watch([devId, cwd], () => {
|
|
60
50
|
branchMenu.value = false;
|
|
61
51
|
wtMenu.value = false;
|
|
52
|
+
fileDiff.value = null;
|
|
62
53
|
void ensureGit(devId.value || "?", cwd.value || "?");
|
|
63
54
|
});
|
|
64
55
|
const refresh = () => {
|
|
65
56
|
checked.value = new Set();
|
|
57
|
+
fileDiff.value = null;
|
|
66
58
|
return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
|
|
67
59
|
};
|
|
68
60
|
|
|
69
|
-
// changed files: staged+modified default-checked, untracked not — IntelliJ's
|
|
70
|
-
// changelist behavior.
|
|
71
61
|
const files = computed(() => status.value?.files ?? []);
|
|
72
62
|
watch(files, (list) => {
|
|
73
63
|
const next = new Set<string>();
|
|
@@ -75,6 +65,10 @@ watch(files, (list) => {
|
|
|
75
65
|
checked.value = next;
|
|
76
66
|
});
|
|
77
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);
|
|
78
72
|
const toggle = (p: string) => {
|
|
79
73
|
const next = new Set(checked.value);
|
|
80
74
|
if (next.has(p)) next.delete(p);
|
|
@@ -84,32 +78,73 @@ const toggle = (p: string) => {
|
|
|
84
78
|
const toggleAll = () => {
|
|
85
79
|
checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
|
|
86
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
|
+
};
|
|
87
90
|
|
|
88
|
-
async function op(name: "fetch" | "pull" | "
|
|
91
|
+
async function op(name: "fetch" | "pull" | "push" | "branch" | "diff" | "stash-push" | "stash-pop", extra: { message?: string } = {}) {
|
|
89
92
|
if (!devId.value || !cwd.value) return;
|
|
90
93
|
busy.value = name;
|
|
91
94
|
out.value = "";
|
|
92
95
|
try {
|
|
93
|
-
const r = await deviceClient(devId.value).gitOp(cwd.value, name);
|
|
96
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, name, extra);
|
|
94
97
|
out.value = r.text || "(empty)";
|
|
95
|
-
if (
|
|
98
|
+
if (["fetch", "pull", "push", "stash-push", "stash-pop"].includes(name)) notify(`git ${name} ✓`);
|
|
96
99
|
await refresh();
|
|
97
|
-
|
|
100
|
+
void loadLog(true);
|
|
98
101
|
} catch (e) {
|
|
99
|
-
|
|
102
|
+
notify(`git ${name}: ${(e as Error).message.slice(0, 160)}`);
|
|
100
103
|
} finally {
|
|
101
104
|
busy.value = "";
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
async function
|
|
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);
|
|
117
|
+
await refresh();
|
|
118
|
+
} catch (e) {
|
|
119
|
+
notify(`${name} failed: ${(e as Error).message.slice(0, 140)}`);
|
|
120
|
+
} finally {
|
|
121
|
+
busy.value = "";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
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) {
|
|
106
140
|
branchMenu.value = false;
|
|
107
|
-
if (!devId.value || !cwd.value || name === status.value?.branch) return;
|
|
141
|
+
if (!devId.value || !cwd.value || (name === status.value?.branch && !create)) return;
|
|
108
142
|
busy.value = "switch";
|
|
109
143
|
try {
|
|
110
|
-
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 });
|
|
111
145
|
notify(r.text || `switched to ${name}`);
|
|
112
146
|
await refresh();
|
|
147
|
+
void loadLog(true);
|
|
113
148
|
} catch (e) {
|
|
114
149
|
notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
|
|
115
150
|
} finally {
|
|
@@ -147,10 +182,7 @@ async function addWorktree(branch: string) {
|
|
|
147
182
|
const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
|
|
148
183
|
busy.value = "wt";
|
|
149
184
|
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
|
-
});
|
|
185
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", { branch, target });
|
|
154
186
|
notify(r.text.split("\n")[0] ?? `worktree ${target}`);
|
|
155
187
|
newBranch.value = "";
|
|
156
188
|
await refresh();
|
|
@@ -161,14 +193,12 @@ async function addWorktree(branch: string) {
|
|
|
161
193
|
}
|
|
162
194
|
}
|
|
163
195
|
|
|
164
|
-
/** A worktree is a working copy — one click and it is a SESSION (herd flow). */
|
|
165
196
|
async function sessionOn(path: string, branch: string | null) {
|
|
166
197
|
if (!devId.value) return;
|
|
167
198
|
busy.value = "session";
|
|
168
199
|
try {
|
|
169
200
|
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];
|
|
201
|
+
const model = (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
|
|
172
202
|
if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
|
|
173
203
|
const s = await deviceClient(devId.value).createSession({
|
|
174
204
|
name: branch ?? path.slice(path.lastIndexOf("/") + 1),
|
|
@@ -197,117 +227,190 @@ async function removeWorktree(path: string) {
|
|
|
197
227
|
busy.value = "";
|
|
198
228
|
}
|
|
199
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
|
+
});
|
|
200
257
|
const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
|
|
201
258
|
</script>
|
|
202
259
|
|
|
203
260
|
<template>
|
|
204
|
-
<div class="git-
|
|
205
|
-
<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>
|
|
206
263
|
<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>
|
|
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>
|
|
216
267
|
|
|
217
268
|
<div v-if="status" :class="{ stale: !!error || !!refreshing }">
|
|
218
|
-
<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>
|
|
219
270
|
|
|
220
271
|
<template v-else>
|
|
221
|
-
<!-- branch
|
|
222
|
-
<div class="
|
|
223
|
-
<
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
<ChevronDown :size="14" />
|
|
228
|
-
</button>
|
|
229
|
-
<span class="muted">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }} · {{ status.head }}</span>
|
|
230
|
-
<div v-if="branchMenu" class="menu">
|
|
231
|
-
<button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
|
|
232
|
-
<span>{{ b.name }}</span>
|
|
233
|
-
<span class="muted">{{ b.current ? "● current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
|
|
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" />
|
|
234
278
|
</button>
|
|
235
|
-
<
|
|
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>
|
|
236
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>
|
|
237
295
|
</div>
|
|
238
296
|
|
|
239
297
|
<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('
|
|
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>
|
|
244
303
|
</div>
|
|
245
304
|
|
|
246
|
-
<!-- COMMIT
|
|
247
|
-
<section class="
|
|
248
|
-
<div class="
|
|
249
|
-
|
|
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">
|
|
250
313
|
<label class="file all">
|
|
251
|
-
<input type="checkbox" :checked="
|
|
252
|
-
<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>
|
|
253
318
|
</label>
|
|
254
|
-
<
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
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>
|
|
258
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>
|
|
259
348
|
</div>
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
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" : ""}` : "" }}
|
|
263
364
|
</button>
|
|
264
365
|
</section>
|
|
265
366
|
|
|
266
|
-
<!-- LOG
|
|
267
|
-
<section class="
|
|
268
|
-
<div class="
|
|
269
|
-
<
|
|
270
|
-
<button :disabled="logLoading" title="refresh log" @click="loadLog(true)"
|
|
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>
|
|
271
372
|
</div>
|
|
272
|
-
<p v-if="logLoading && !commits.length" class="
|
|
373
|
+
<p v-if="logLoading && !commits.length" class="hint">loading log…</p>
|
|
273
374
|
<div v-else-if="commits.length" class="commits">
|
|
274
|
-
<div v-for="c in commits" :key="c.hash" class="commit-row">
|
|
375
|
+
<div v-for="c in commits" :key="c.hash" class="commit-row" :title="c.refs">
|
|
275
376
|
<code class="hash">{{ c.hash }}</code>
|
|
276
|
-
<span v-if="c.refs" class="refs"
|
|
377
|
+
<span v-if="c.refs" class="refs">{{ c.refs.split(",")[0] }}</span>
|
|
277
378
|
<span class="subject">{{ c.subject }}</span>
|
|
278
|
-
<span class="muted who">{{ c.author }} · {{ c.date }}</span>
|
|
379
|
+
<span class="muted-line who">{{ c.author }} · {{ c.date }}</span>
|
|
279
380
|
</div>
|
|
280
381
|
</div>
|
|
281
|
-
<p v-else class="
|
|
382
|
+
<p v-else-if="logError" class="message-error">log failed: {{ logError }}</p>
|
|
383
|
+
<p v-else class="hint">no commits</p>
|
|
282
384
|
</section>
|
|
283
385
|
|
|
284
|
-
<!--
|
|
285
|
-
<section class="
|
|
286
|
-
<div class="
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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>
|
|
297
400
|
</div>
|
|
298
401
|
</div>
|
|
299
402
|
</div>
|
|
300
403
|
<div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
|
|
301
404
|
<code :title="w.path">{{ w.path }}</code>
|
|
302
|
-
<span class="muted">{{ w.branch ?? w.head }}</span>
|
|
405
|
+
<span class="muted-line">{{ w.branch ?? w.head }}</span>
|
|
303
406
|
<span class="grow" />
|
|
304
|
-
<button :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
|
|
305
|
-
<button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="
|
|
306
|
-
<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" />
|
|
307
410
|
</div>
|
|
308
411
|
</section>
|
|
309
412
|
|
|
310
|
-
<pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
|
|
413
|
+
<pre v-if="out" class="op-out">{{ out.slice(0, 6000) }}</pre>
|
|
311
414
|
</template>
|
|
312
415
|
</div>
|
|
313
416
|
</template>
|
|
@@ -315,49 +418,43 @@ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w
|
|
|
315
418
|
</template>
|
|
316
419
|
|
|
317
420
|
<style scoped>
|
|
318
|
-
.git-
|
|
319
|
-
.git-
|
|
320
|
-
.
|
|
321
|
-
.branch-
|
|
322
|
-
.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; }
|
|
323
426
|
.menu {
|
|
324
|
-
position: absolute; top: calc(100% +
|
|
325
|
-
background: var(--bg-2, #
|
|
326
|
-
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%);
|
|
327
430
|
}
|
|
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
|
-
|
|
345
|
-
.
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
.
|
|
350
|
-
.
|
|
351
|
-
.wt { display: flex; gap: 8px; align-items: center; }
|
|
352
|
-
.wt code { font-size:
|
|
353
|
-
.wt.main code { font-weight:
|
|
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; }
|
|
354
457
|
.grow { flex: 1; }
|
|
355
|
-
.
|
|
356
|
-
|
|
357
|
-
.muted { opacity: 0.65; }
|
|
358
|
-
.note { font-size: 11px; }
|
|
359
|
-
.stale { opacity: 0.55; transition: opacity 0.2s; }
|
|
360
|
-
.out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
|
|
361
|
-
.empty { opacity: 0.75; }
|
|
362
|
-
.error { color: var(--danger, #f85149); }
|
|
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; }
|
|
363
460
|
</style>
|
|
@@ -67,7 +67,13 @@ export type GitOpName =
|
|
|
67
67
|
| "commit"
|
|
68
68
|
| "worktree-list"
|
|
69
69
|
| "worktree-add"
|
|
70
|
-
| "worktree-remove"
|
|
70
|
+
| "worktree-remove"
|
|
71
|
+
| "push"
|
|
72
|
+
| "stage"
|
|
73
|
+
| "discard"
|
|
74
|
+
| "diff-file"
|
|
75
|
+
| "stash-push"
|
|
76
|
+
| "stash-pop";
|
|
71
77
|
|
|
72
78
|
export class DpiClient {
|
|
73
79
|
readonly baseUrl: string;
|
|
@@ -181,7 +187,7 @@ export class DpiClient {
|
|
|
181
187
|
gitOp(
|
|
182
188
|
path: string,
|
|
183
189
|
op: GitOpName,
|
|
184
|
-
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 } = {},
|
|
185
191
|
) {
|
|
186
192
|
return this.request<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }>(
|
|
187
193
|
"POST",
|