my-durable 0.0.22 → 0.0.24
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 +56 -4
- package/apps/dpi-backend/src/routes.ts +7 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BLV50tg7.js +78 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CpVvdDil.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/App.vue +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/git-store.ts +101 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/session-git.ts +28 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitChip.vue +22 -159
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitPanel.vue +278 -137
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +6 -0
- package/apps/dpi-sdk/src/client.ts +17 -2
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/apps/dpi-tui/src/app.ts +25 -2
- package/apps/dpi-tui/src/views.ts +7 -3
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-D5Ehu13_.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-D7bC_-V3.js +0 -80
|
@@ -1,81 +1,100 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Inspector GIT tab:
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// Inspector GIT tab, IntelliJ-shaped:
|
|
3
|
+
// • branch PICKER — the list is right there, nobody types an existing name
|
|
4
|
+
// • worktrees are first-class rows: create a SESSION on one click
|
|
5
|
+
// • commit: big multi-line box + changed-file checkboxes (commit chosen files)
|
|
6
|
+
// Lazy: rides the shared git-store (one fetch per folder while fresh), the
|
|
7
|
+
// refresh button and every op are the only forced refetches.
|
|
8
|
+
import { computed, onServerPrefetch, ref, watch } from "vue";
|
|
9
|
+
import { Check, ChevronDown, RefreshCw, X } from "lucide-vue-next";
|
|
10
|
+
import { deviceId, network, sessionSummary, notify, select } from "../client/store";
|
|
7
11
|
import { deviceClient } from "../client/hub";
|
|
12
|
+
import { ensureGit, gitEntry } from "../client/git-store";
|
|
8
13
|
import type { GitStatus } from "@sdk/client";
|
|
9
14
|
|
|
10
15
|
const props = defineProps<{ deviceId?: string; cwd?: string }>();
|
|
11
|
-
const status = ref<GitStatus | null>(null);
|
|
12
|
-
const error = ref("");
|
|
13
|
-
const loading = ref(false); // first fetch, nothing to show yet — SAY SO
|
|
14
|
-
const refreshing = ref(false); // refetch with data on screen — dim, keep, never blank
|
|
15
|
-
let inFlight: Promise<void> | undefined;
|
|
16
|
-
const busy = ref("");
|
|
17
|
-
const out = ref("");
|
|
18
|
-
const commitMsg = ref("");
|
|
19
|
-
const wtName = ref("");
|
|
20
16
|
|
|
21
17
|
const cwd = computed(() => props.cwd ?? sessionSummary.value?.cwd ?? "");
|
|
22
18
|
const devId = computed(() => props.deviceId ?? deviceId.value);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
} finally {
|
|
49
|
-
loading.value = false;
|
|
50
|
-
refreshing.value = false;
|
|
51
|
-
inFlight = undefined;
|
|
52
|
-
}
|
|
53
|
-
})();
|
|
54
|
-
return inFlight;
|
|
19
|
+
const entry = computed(() => gitEntry(devId.value || "?", cwd.value || "?"));
|
|
20
|
+
const status = computed(() => entry.value.status);
|
|
21
|
+
const loading = computed(() => entry.value.loading);
|
|
22
|
+
const refreshing = computed(() => entry.value.refreshing);
|
|
23
|
+
const error = computed(() => entry.value.error);
|
|
24
|
+
|
|
25
|
+
const busy = ref("");
|
|
26
|
+
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
|
+
}
|
|
55
44
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
onServerPrefetch(() => refresh());
|
|
62
|
-
onMounted(() => {
|
|
63
|
-
timer = setInterval(refresh, 30000);
|
|
45
|
+
if (!import.meta.env.SSR) void loadLog();
|
|
46
|
+
watch([devId, cwd], () => {
|
|
47
|
+
commits.value = [];
|
|
48
|
+
logAt = 0;
|
|
49
|
+
void loadLog();
|
|
64
50
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
51
|
+
const commitMsg = ref("");
|
|
52
|
+
const checked = ref<Set<string>>(new Set());
|
|
53
|
+
const branchMenu = ref(false);
|
|
54
|
+
const wtMenu = ref(false);
|
|
55
|
+
const newBranch = ref("");
|
|
56
|
+
|
|
57
|
+
if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
|
|
58
|
+
onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
|
|
59
|
+
watch([devId, cwd], () => {
|
|
60
|
+
branchMenu.value = false;
|
|
61
|
+
wtMenu.value = false;
|
|
62
|
+
void ensureGit(devId.value || "?", cwd.value || "?");
|
|
63
|
+
});
|
|
64
|
+
const refresh = () => {
|
|
65
|
+
checked.value = new Set();
|
|
66
|
+
return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// changed files: staged+modified default-checked, untracked not — IntelliJ's
|
|
70
|
+
// changelist behavior.
|
|
71
|
+
const files = computed(() => status.value?.files ?? []);
|
|
72
|
+
watch(files, (list) => {
|
|
73
|
+
const next = new Set<string>();
|
|
74
|
+
for (const f of list) if (f.x !== "?" && f.y !== "?") next.add(f.path);
|
|
75
|
+
checked.value = next;
|
|
76
|
+
});
|
|
77
|
+
const someChecked = computed(() => files.value.some((f) => checked.value.has(f.path)));
|
|
78
|
+
const toggle = (p: string) => {
|
|
79
|
+
const next = new Set(checked.value);
|
|
80
|
+
if (next.has(p)) next.delete(p);
|
|
81
|
+
else next.add(p);
|
|
82
|
+
checked.value = next;
|
|
83
|
+
};
|
|
84
|
+
const toggleAll = () => {
|
|
85
|
+
checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
|
|
86
|
+
};
|
|
68
87
|
|
|
69
88
|
async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
|
|
70
|
-
|
|
71
|
-
if (!c || !cwd.value) return;
|
|
89
|
+
if (!devId.value || !cwd.value) return;
|
|
72
90
|
busy.value = name;
|
|
73
91
|
out.value = "";
|
|
74
92
|
try {
|
|
75
|
-
const r = await
|
|
93
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, name);
|
|
76
94
|
out.value = r.text || "(empty)";
|
|
77
95
|
if (name === "fetch" || name === "pull") notify(`git ${name} ✓`);
|
|
78
96
|
await refresh();
|
|
97
|
+
if (name === "fetch" || name === "pull" || name === "branch") void loadLog(true);
|
|
79
98
|
} catch (e) {
|
|
80
99
|
out.value = `${name} failed: ${(e as Error).message.slice(0, 200)}`;
|
|
81
100
|
} finally {
|
|
@@ -83,55 +102,102 @@ async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
|
|
|
83
102
|
}
|
|
84
103
|
}
|
|
85
104
|
|
|
105
|
+
async function switchBranch(name: string) {
|
|
106
|
+
branchMenu.value = false;
|
|
107
|
+
if (!devId.value || !cwd.value || name === status.value?.branch) return;
|
|
108
|
+
busy.value = "switch";
|
|
109
|
+
try {
|
|
110
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name });
|
|
111
|
+
notify(r.text || `switched to ${name}`);
|
|
112
|
+
await refresh();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
|
|
115
|
+
} finally {
|
|
116
|
+
busy.value = "";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
async function commit() {
|
|
87
|
-
|
|
88
|
-
if (!c || !cwd.value || !commitMsg.value.trim()) return;
|
|
121
|
+
if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
|
|
89
122
|
busy.value = "commit";
|
|
123
|
+
const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
|
|
124
|
+
if (!chosen.length) return notify("check the files to commit — nothing is selected");
|
|
90
125
|
try {
|
|
91
|
-
const r = await
|
|
126
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
|
|
127
|
+
message: commitMsg.value.trim(),
|
|
128
|
+
files: chosen,
|
|
129
|
+
});
|
|
92
130
|
notify(r.text);
|
|
93
131
|
commitMsg.value = "";
|
|
94
132
|
await refresh();
|
|
133
|
+
void loadLog(true);
|
|
95
134
|
} catch (e) {
|
|
96
|
-
notify(`commit failed: ${(e as Error).message.slice(0,
|
|
135
|
+
notify(`commit failed: ${(e as Error).message.slice(0, 140)}`);
|
|
97
136
|
} finally {
|
|
98
137
|
busy.value = "";
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
|
|
102
|
-
async function addWorktree() {
|
|
103
|
-
|
|
104
|
-
if (!
|
|
141
|
+
async function addWorktree(branch: string) {
|
|
142
|
+
wtMenu.value = false;
|
|
143
|
+
if (!devId.value || !cwd.value || !status.value?.root) return;
|
|
105
144
|
const root = status.value.root;
|
|
106
145
|
const parent = root.slice(0, root.lastIndexOf("/"));
|
|
107
146
|
const base = root.slice(root.lastIndexOf("/") + 1);
|
|
147
|
+
const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
|
|
108
148
|
busy.value = "wt";
|
|
109
149
|
try {
|
|
110
|
-
const r = await
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
});
|
|
154
|
+
notify(r.text.split("\n")[0] ?? `worktree ${target}`);
|
|
155
|
+
newBranch.value = "";
|
|
113
156
|
await refresh();
|
|
114
157
|
} catch (e) {
|
|
115
|
-
notify(`worktree failed: ${(e as Error).message.slice(0,
|
|
158
|
+
notify(`worktree failed: ${(e as Error).message.slice(0, 140)}`);
|
|
159
|
+
} finally {
|
|
160
|
+
busy.value = "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A worktree is a working copy — one click and it is a SESSION (herd flow). */
|
|
165
|
+
async function sessionOn(path: string, branch: string | null) {
|
|
166
|
+
if (!devId.value) return;
|
|
167
|
+
busy.value = "session";
|
|
168
|
+
try {
|
|
169
|
+
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];
|
|
172
|
+
if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
|
|
173
|
+
const s = await deviceClient(devId.value).createSession({
|
|
174
|
+
name: branch ?? path.slice(path.lastIndexOf("/") + 1),
|
|
175
|
+
cwd: path,
|
|
176
|
+
model: { provider: model.provider, modelId: model.id },
|
|
177
|
+
});
|
|
178
|
+
select({ deviceId: devId.value, sessionId: s.id, agentId: 1 });
|
|
179
|
+
notify(`session "${s.name}" on ${path}`);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
notify(`session failed: ${(e as Error).message.slice(0, 160)}`);
|
|
116
182
|
} finally {
|
|
117
183
|
busy.value = "";
|
|
118
184
|
}
|
|
119
185
|
}
|
|
120
186
|
|
|
121
187
|
async function removeWorktree(path: string) {
|
|
122
|
-
|
|
123
|
-
if (!c || !cwd.value) return;
|
|
188
|
+
if (!devId.value || !cwd.value) return;
|
|
124
189
|
if (!confirm(`Remove worktree ${path}?`)) return;
|
|
125
190
|
busy.value = "wt";
|
|
126
191
|
try {
|
|
127
|
-
await
|
|
192
|
+
await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
|
|
128
193
|
await refresh();
|
|
129
194
|
} catch (e) {
|
|
130
|
-
notify(`remove failed: ${(e as Error).message.slice(0,
|
|
195
|
+
notify(`remove failed: ${(e as Error).message.slice(0, 140)}`);
|
|
131
196
|
} finally {
|
|
132
197
|
busy.value = "";
|
|
133
198
|
}
|
|
134
199
|
}
|
|
200
|
+
const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
|
|
135
201
|
</script>
|
|
136
202
|
|
|
137
203
|
<template>
|
|
@@ -141,82 +207,157 @@ async function removeWorktree(path: string) {
|
|
|
141
207
|
<header class="git-head">
|
|
142
208
|
<strong>git</strong>
|
|
143
209
|
<code class="cwd" :title="cwd">{{ cwd }}</code>
|
|
144
|
-
<button :disabled="!!busy" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
210
|
+
<button :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
145
211
|
</header>
|
|
146
212
|
|
|
147
213
|
<p v-if="loading" class="empty">loading git…</p>
|
|
148
|
-
<p v-if="refreshing" class="muted note">refreshing…</p>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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>
|
|
216
|
+
|
|
217
|
+
<div v-if="status" :class="{ stale: !!error || !!refreshing }">
|
|
218
|
+
<p v-if="!status.repo" class="empty">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
|
|
219
|
+
|
|
220
|
+
<template v-else>
|
|
221
|
+
<!-- branch bar: PICK, never type -->
|
|
222
|
+
<div class="branch-bar">
|
|
223
|
+
<button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
|
|
224
|
+
<strong>{{ status.branch ?? "detached" }}</strong>
|
|
225
|
+
<span v-if="status.upstream" class="muted">→ {{ status.upstream }}</span>
|
|
226
|
+
<span v-if="status.ahead || status.behind" class="muted">↑{{ status.ahead ?? 0 }} ↓{{ status.behind ?? 0 }}</span>
|
|
227
|
+
<ChevronDown :size="14" />
|
|
228
|
+
</button>
|
|
229
|
+
<span class="muted">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }} · {{ status.head }}</span>
|
|
230
|
+
<div v-if="branchMenu" class="menu">
|
|
231
|
+
<button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
|
|
232
|
+
<span>{{ b.name }}</span>
|
|
233
|
+
<span class="muted">{{ b.current ? "● current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
|
|
234
|
+
</button>
|
|
235
|
+
<p v-if="!(status.branches ?? []).length" class="muted pad">no local branches</p>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div class="row ops">
|
|
240
|
+
<button :disabled="!!busy" @click="op('fetch')">fetch</button>
|
|
241
|
+
<button :disabled="!!busy" @click="op('pull')">pull</button>
|
|
242
|
+
<button :disabled="!!busy" @click="op('branch')">branches</button>
|
|
243
|
+
<button :disabled="!!busy" @click="op('diff')">diff</button>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- COMMIT: always present — IntelliJ's Ctrl+K opens on a clean tree too -->
|
|
247
|
+
<section class="commit">
|
|
248
|
+
<div class="sec-head"><strong>Commit</strong><span class="muted">{{ files.length ? `${checked.size}/${files.length} files` : "no changes" }}</span></div>
|
|
249
|
+
<div v-if="files.length" class="files">
|
|
250
|
+
<label class="file all">
|
|
251
|
+
<input type="checkbox" :checked="someChecked" @change="toggleAll" />
|
|
252
|
+
<span class="muted">select all / none</span>
|
|
253
|
+
</label>
|
|
254
|
+
<label v-for="f in files" :key="f.path" class="file">
|
|
255
|
+
<input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
|
|
256
|
+
<code class="xy">{{ f.x }}{{ f.y }}</code>
|
|
257
|
+
<span class="path" :title="f.path">{{ f.path }}</span>
|
|
258
|
+
</label>
|
|
259
|
+
</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
|
+
</section>
|
|
265
|
+
|
|
266
|
+
<!-- LOG: the IDE centerpiece — hash, refs, author, date, subject -->
|
|
267
|
+
<section class="logsec">
|
|
268
|
+
<div class="sec-head">
|
|
269
|
+
<strong>Log</strong>
|
|
270
|
+
<button :disabled="logLoading" title="refresh log" @click="loadLog(true)">↻</button>
|
|
271
|
+
</div>
|
|
272
|
+
<p v-if="logLoading && !commits.length" class="muted">loading log…</p>
|
|
273
|
+
<div v-else-if="commits.length" class="commits">
|
|
274
|
+
<div v-for="c in commits" :key="c.hash" class="commit-row">
|
|
275
|
+
<code class="hash">{{ c.hash }}</code>
|
|
276
|
+
<span v-if="c.refs" class="refs" :title="c.refs">{{ c.refs.split(",")[0] }}</span>
|
|
277
|
+
<span class="subject">{{ c.subject }}</span>
|
|
278
|
+
<span class="muted who">{{ c.author }} · {{ c.date }}</span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
<p v-else class="muted">no commits</p>
|
|
282
|
+
</section>
|
|
283
|
+
|
|
284
|
+
<!-- worktrees: local + linked, one click to a session -->
|
|
285
|
+
<section class="wts">
|
|
286
|
+
<div class="wt-head">
|
|
287
|
+
<strong>Worktrees</strong>
|
|
288
|
+
<button :disabled="!!busy" @click="wtMenu = !wtMenu">+ worktree</button>
|
|
289
|
+
<div v-if="wtMenu" class="menu">
|
|
290
|
+
<button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
|
|
291
|
+
<span>{{ b.name }}</span>
|
|
292
|
+
<span class="muted">existing branch</span>
|
|
293
|
+
</button>
|
|
294
|
+
<div class="new-branch">
|
|
295
|
+
<input v-model="newBranch" placeholder="or a NEW branch name…" @keyup.enter="addWorktree(newBranch.trim() || 'wt')" />
|
|
296
|
+
<button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
<div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
|
|
301
|
+
<code :title="w.path">{{ w.path }}</code>
|
|
302
|
+
<span class="muted">{{ w.branch ?? w.head }}</span>
|
|
303
|
+
<span class="grow" />
|
|
304
|
+
<button :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
|
|
305
|
+
<button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="12" /></button>
|
|
306
|
+
<Check v-if="w.path === status.root" :size="12" class="muted" />
|
|
307
|
+
</div>
|
|
308
|
+
</section>
|
|
309
|
+
|
|
310
|
+
<pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
|
|
311
|
+
</template>
|
|
197
312
|
</div>
|
|
198
313
|
</template>
|
|
199
314
|
</div>
|
|
200
315
|
</template>
|
|
201
316
|
|
|
202
317
|
<style scoped>
|
|
203
|
-
.git-tab { display: grid; gap:
|
|
318
|
+
.git-tab { display: grid; gap: 10px; font-size: 12px; padding: 8px 4px; }
|
|
204
319
|
.git-head { display: flex; align-items: center; gap: 8px; }
|
|
205
320
|
.git-head .cwd { font-size: 11px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
206
|
-
.
|
|
207
|
-
.
|
|
208
|
-
.
|
|
209
|
-
|
|
321
|
+
.branch-bar { position: relative; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
322
|
+
.branch-btn { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; font-size: 12px; }
|
|
323
|
+
.menu {
|
|
324
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 260px; max-height: 260px; overflow: auto;
|
|
325
|
+
background: var(--bg-2, #1c1c22); border: 1px solid var(--border, #333); border-radius: 8px; padding: 4px;
|
|
326
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
|
327
|
+
}
|
|
328
|
+
.menu button { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 5px 8px; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; }
|
|
329
|
+
.menu button:hover, .menu button.cur { background: rgb(127 127 127 / 15%); }
|
|
330
|
+
.menu .pad { padding: 6px 8px; margin: 0; }
|
|
210
331
|
.row { display: flex; gap: 6px; }
|
|
211
|
-
.
|
|
212
|
-
.
|
|
213
|
-
.
|
|
214
|
-
.
|
|
332
|
+
.ops { flex-wrap: wrap; }
|
|
333
|
+
.sec-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 2px; border-bottom: 1px solid var(--border, #2a2a30); }
|
|
334
|
+
.commit { display: grid; gap: 6px; }
|
|
335
|
+
.logsec { display: grid; gap: 6px; }
|
|
336
|
+
.commits { max-height: 320px; overflow: auto; display: grid; gap: 1px; }
|
|
337
|
+
.commit-row { display: flex; gap: 6px; align-items: baseline; white-space: nowrap; }
|
|
338
|
+
.commit-row .hash { color: var(--ok, #2ea043); font-size: 11px; }
|
|
339
|
+
.commit-row .refs { color: var(--warn, #e5a50a); font-size: 10px; max-width: 16ch; overflow: hidden; text-overflow: ellipsis; }
|
|
340
|
+
.commit-row .subject { overflow: hidden; text-overflow: ellipsis; max-width: 40%; }
|
|
341
|
+
.commit-row .who { font-size: 10px; }
|
|
342
|
+
.files { max-height: 180px; overflow: auto; border: 1px solid var(--border, #2a2a30); border-radius: 6px; padding: 2px 6px; }
|
|
343
|
+
.file { display: flex; gap: 6px; align-items: center; padding: 2px 0; }
|
|
344
|
+
.file.all { border-bottom: 1px solid var(--border, #2a2a30); }
|
|
345
|
+
.file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
346
|
+
.xy { opacity: 0.7; font-size: 10px; }
|
|
347
|
+
textarea { width: 100%; resize: vertical; min-height: 64px; font: inherit; }
|
|
348
|
+
button.primary { justify-self: start; }
|
|
349
|
+
.wts { display: grid; gap: 4px; }
|
|
350
|
+
.wt-head { position: relative; display: flex; align-items: center; gap: 8px; }
|
|
351
|
+
.wt { display: flex; gap: 8px; align-items: center; }
|
|
352
|
+
.wt code { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46%; }
|
|
353
|
+
.wt.main code { font-weight: 600; }
|
|
354
|
+
.grow { flex: 1; }
|
|
355
|
+
.new-branch { display: flex; gap: 6px; padding: 6px 8px; border-top: 1px solid var(--border, #2a2a30); }
|
|
356
|
+
.new-branch input { flex: 1; min-width: 0; }
|
|
215
357
|
.muted { opacity: 0.65; }
|
|
216
|
-
.wt-of dd { color: var(--info, #58a6ff); }
|
|
217
|
-
.out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
|
|
218
|
-
.empty { opacity: 0.75; }
|
|
219
358
|
.note { font-size: 11px; }
|
|
220
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; }
|
|
221
362
|
.error { color: var(--danger, #f85149); }
|
|
222
363
|
</style>
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import DeviceIcon from "./DeviceIcon.vue";
|
|
17
17
|
import type { TreeNode } from "@sdk/models";
|
|
18
18
|
import { ago } from "../client/store";
|
|
19
|
+
import { branchOf } from "../client/session-git";
|
|
19
20
|
|
|
20
21
|
const props = defineProps<{
|
|
21
22
|
node: TreeNode;
|
|
@@ -60,6 +61,10 @@ const dot = computed(() => {
|
|
|
60
61
|
const s = device.value?.status;
|
|
61
62
|
return s === "online" ? "on" : s === "checking" ? "chk" : s === "offline" ? "off" : "err";
|
|
62
63
|
});
|
|
64
|
+
const branch = computed(() => {
|
|
65
|
+
const n = props.node;
|
|
66
|
+
return n.type === "session" ? branchOf(n.deviceId, n.session.cwd) : undefined;
|
|
67
|
+
});
|
|
63
68
|
const label = computed(
|
|
64
69
|
() => device.value?.name ?? device.value?.id ?? session.value?.name ?? agent.value?.name ?? "",
|
|
65
70
|
);
|
|
@@ -137,6 +142,7 @@ const title = computed(() => {
|
|
|
137
142
|
<FolderOpen class="ico" :size="16" />
|
|
138
143
|
<b class="name">{{ label }}</b>
|
|
139
144
|
<span class="path">…/{{ session.cwd.split("/").filter(Boolean).at(-1) }}</span>
|
|
145
|
+
<span v-if="branch" class="branch" :title="`git branch of ${session.cwd}`">⎇ {{ branch }}</span>
|
|
140
146
|
<i v-if="busy" class="dot work" title="Working" />
|
|
141
147
|
<span v-if="background" class="bg-count" :title="`${background} background task(s)`"
|
|
142
148
|
><i class="dot bg" />{{ background }} bg</span
|
|
@@ -53,10 +53,13 @@ export interface GitStatus {
|
|
|
53
53
|
subject?: string;
|
|
54
54
|
worktreeOf?: string;
|
|
55
55
|
worktrees?: { path: string; branch: string | null; head: string }[];
|
|
56
|
+
files?: { path: string; x: string; y: string }[];
|
|
57
|
+
branches?: { name: string; current?: boolean; upstream?: string }[];
|
|
56
58
|
}
|
|
57
59
|
export type GitOpName =
|
|
58
60
|
| "diff"
|
|
59
61
|
| "log"
|
|
62
|
+
| "log-list"
|
|
60
63
|
| "fetch"
|
|
61
64
|
| "pull"
|
|
62
65
|
| "switch"
|
|
@@ -175,8 +178,20 @@ export class DpiClient {
|
|
|
175
178
|
return this.request<GitStatus>("GET", `/v1/git/status?path=${enc(path)}`, undefined, options);
|
|
176
179
|
}
|
|
177
180
|
/** One whitelisted git operation; see the device's git.ts for the op list. */
|
|
178
|
-
gitOp(
|
|
179
|
-
|
|
181
|
+
gitOp(
|
|
182
|
+
path: string,
|
|
183
|
+
op: GitOpName,
|
|
184
|
+
opts: { ref?: string; branch?: string; message?: string; target?: string; files?: string[] } = {},
|
|
185
|
+
) {
|
|
186
|
+
return this.request<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }>(
|
|
187
|
+
"POST",
|
|
188
|
+
"/v1/git",
|
|
189
|
+
{ path, op, ...opts },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
/** Many paths, one round-trip — session lists want branch-per-cwd. */
|
|
193
|
+
gitStatusBatch(paths: string[], options: { timeoutMs?: number } = {}) {
|
|
194
|
+
return this.request<Record<string, GitStatus>>("POST", "/v1/git/status-batch", { paths }, options);
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
// ------------------------------------------------------------ sessions
|
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -172,6 +172,27 @@ export class App {
|
|
|
172
172
|
private gitOf(cwd: string | undefined): GitStatus | undefined {
|
|
173
173
|
return cwd ? this.gitCache.get(cwd)?.s : undefined;
|
|
174
174
|
}
|
|
175
|
+
|
|
176
|
+
/** ⎇ per herd ROW: one batched status for all distinct cwds, 60s TTL. */
|
|
177
|
+
private branchCache = new Map<string, { at: number; branch?: string }>();
|
|
178
|
+
private refreshBranches() {
|
|
179
|
+
const cwds = [...new Set(this.sessions.map((s) => s.cwd))];
|
|
180
|
+
const stale = cwds.filter((c) => {
|
|
181
|
+
const hit = this.branchCache.get(c);
|
|
182
|
+
return !hit || Date.now() - hit.at > 60_000;
|
|
183
|
+
});
|
|
184
|
+
if (!stale.length) return;
|
|
185
|
+
for (const c of stale) this.branchCache.set(c, { at: Date.now() });
|
|
186
|
+
void this.client
|
|
187
|
+
.gitStatusBatch(stale)
|
|
188
|
+
.then((map) => {
|
|
189
|
+
for (const [c, s] of Object.entries(map))
|
|
190
|
+
this.branchCache.set(c, { at: Date.now(), branch: s.repo ? (s.branch ?? s.head) : undefined });
|
|
191
|
+
this.requestRender();
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
private branchOf = (cwd: string): string | undefined => this.branchCache.get(cwd)?.branch;
|
|
175
196
|
private devicesCache: DeviceRow[] = [];
|
|
176
197
|
private deviceOnline = false;
|
|
177
198
|
private events: { at: string; glyph: string; text: string }[] = [];
|
|
@@ -345,6 +366,7 @@ export class App {
|
|
|
345
366
|
this.cursor = 0;
|
|
346
367
|
this.scrollOffset = 0;
|
|
347
368
|
if (view === "herd") void this.refreshSessions();
|
|
369
|
+
if (view === "herd") this.refreshBranches();
|
|
348
370
|
if (view === "network") this.devicesCache = this.networkDevices();
|
|
349
371
|
this.requestRender();
|
|
350
372
|
}
|
|
@@ -391,7 +413,7 @@ export class App {
|
|
|
391
413
|
deviceId?: string;
|
|
392
414
|
label: string;
|
|
393
415
|
}> {
|
|
394
|
-
if (this.view === "herd") return herdRows(this.sessions, this.filter);
|
|
416
|
+
if (this.view === "herd") return herdRows(this.sessions, this.filter, this.branchOf);
|
|
395
417
|
if (this.view === "network") return networkRows(this.devicesCache, this.device.id, this.filter);
|
|
396
418
|
return [];
|
|
397
419
|
}
|
|
@@ -981,10 +1003,11 @@ export class App {
|
|
|
981
1003
|
// ------------------------------------------------------------------ herd
|
|
982
1004
|
|
|
983
1005
|
private herdBody(w: number, h: number): string[] {
|
|
984
|
-
const rows = herdRows(this.sessions, this.filter);
|
|
1006
|
+
const rows = herdRows(this.sessions, this.filter, this.branchOf);
|
|
985
1007
|
const wide = w >= 104;
|
|
986
1008
|
const panelW = wide ? Math.min(44, Math.max(34, Math.round(w * 0.32))) : 0;
|
|
987
1009
|
const tableW = wide ? w - panelW - 1 : w;
|
|
1010
|
+
this.refreshBranches();
|
|
988
1011
|
const table = renderHerd(tableW, rows, this.sessions, {
|
|
989
1012
|
selectedId: this.selectedId ?? this.sessionId,
|
|
990
1013
|
cursor: this.cursor,
|