my-durable 0.0.21 → 0.0.23
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 +66 -11
- package/apps/dpi-backend/src/routes.ts +6 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BT2MDDdt.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CclVXMo8.js +78 -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 +225 -134
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +6 -0
- package/apps/dpi-sdk/src/client.ts +15 -4
- 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-BvDyZN2S.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cv3w8azJ.js +0 -80
|
@@ -1,75 +1,72 @@
|
|
|
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
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
const cwd = computed(() => props.cwd ?? sessionSummary.value?.cwd ?? "");
|
|
18
|
+
const devId = computed(() => props.deviceId ?? deviceId.value);
|
|
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
|
+
|
|
16
25
|
const busy = ref("");
|
|
17
26
|
const out = ref("");
|
|
18
27
|
const commitMsg = ref("");
|
|
19
|
-
const
|
|
28
|
+
const checked = ref<Set<string>>(new Set());
|
|
29
|
+
const branchMenu = ref(false);
|
|
30
|
+
const wtMenu = ref(false);
|
|
31
|
+
const newBranch = ref("");
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// Keep the LAST GOOD status — a dropped tunnel must not blank the tab
|
|
41
|
-
// the operator is reading (reported: "treo rất lâu", "Cannot reach …").
|
|
42
|
-
error.value = /fetch failed|Cannot reach|Failed to fetch|NetworkError|ERR_NAME/i.test(raw)
|
|
43
|
-
? `device unreachable — check that it is online, then retry (${raw.slice(0, 80)})`
|
|
44
|
-
: raw.slice(0, 160);
|
|
45
|
-
} finally {
|
|
46
|
-
loading.value = false;
|
|
47
|
-
refreshing.value = false;
|
|
48
|
-
inFlight = undefined;
|
|
49
|
-
}
|
|
50
|
-
})();
|
|
51
|
-
return inFlight;
|
|
52
|
-
}
|
|
53
|
-
// NO top-level await: an async setup outside <Suspense> never mounts in the
|
|
54
|
-
// browser ("async setup invoked without a Suspense boundary") — the tab
|
|
55
|
-
// rendered EMPTY (reported: "TAB git trống"). Fire now on the client;
|
|
56
|
-
// onServerPrefetch is what SSR actually waits for.
|
|
57
|
-
if (!import.meta.env.SSR) void refresh();
|
|
58
|
-
onServerPrefetch(() => refresh());
|
|
59
|
-
onMounted(() => {
|
|
60
|
-
timer = setInterval(refresh, 10000);
|
|
33
|
+
if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
|
|
34
|
+
onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
|
|
35
|
+
watch([devId, cwd], () => {
|
|
36
|
+
branchMenu.value = false;
|
|
37
|
+
wtMenu.value = false;
|
|
38
|
+
void ensureGit(devId.value || "?", cwd.value || "?");
|
|
39
|
+
});
|
|
40
|
+
const refresh = () => {
|
|
41
|
+
checked.value = new Set();
|
|
42
|
+
return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// changed files: staged+modified default-checked, untracked not — IntelliJ's
|
|
46
|
+
// changelist behavior.
|
|
47
|
+
const files = computed(() => status.value?.files ?? []);
|
|
48
|
+
watch(files, (list) => {
|
|
49
|
+
const next = new Set<string>();
|
|
50
|
+
for (const f of list) if (f.x !== "?" && f.y !== "?") next.add(f.path);
|
|
51
|
+
checked.value = next;
|
|
61
52
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
const someChecked = computed(() => files.value.some((f) => checked.value.has(f.path)));
|
|
54
|
+
const toggle = (p: string) => {
|
|
55
|
+
const next = new Set(checked.value);
|
|
56
|
+
if (next.has(p)) next.delete(p);
|
|
57
|
+
else next.add(p);
|
|
58
|
+
checked.value = next;
|
|
59
|
+
};
|
|
60
|
+
const toggleAll = () => {
|
|
61
|
+
checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
|
|
62
|
+
};
|
|
65
63
|
|
|
66
64
|
async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
|
|
67
|
-
|
|
68
|
-
if (!c || !cwd.value) return;
|
|
65
|
+
if (!devId.value || !cwd.value) return;
|
|
69
66
|
busy.value = name;
|
|
70
67
|
out.value = "";
|
|
71
68
|
try {
|
|
72
|
-
const r = await
|
|
69
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, name);
|
|
73
70
|
out.value = r.text || "(empty)";
|
|
74
71
|
if (name === "fetch" || name === "pull") notify(`git ${name} ✓`);
|
|
75
72
|
await refresh();
|
|
@@ -80,55 +77,100 @@ async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
|
|
|
80
77
|
}
|
|
81
78
|
}
|
|
82
79
|
|
|
80
|
+
async function switchBranch(name: string) {
|
|
81
|
+
branchMenu.value = false;
|
|
82
|
+
if (!devId.value || !cwd.value || name === status.value?.branch) return;
|
|
83
|
+
busy.value = "switch";
|
|
84
|
+
try {
|
|
85
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name });
|
|
86
|
+
notify(r.text || `switched to ${name}`);
|
|
87
|
+
await refresh();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
|
|
90
|
+
} finally {
|
|
91
|
+
busy.value = "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
async function commit() {
|
|
84
|
-
|
|
85
|
-
if (!c || !cwd.value || !commitMsg.value.trim()) return;
|
|
96
|
+
if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
|
|
86
97
|
busy.value = "commit";
|
|
98
|
+
const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
|
|
87
99
|
try {
|
|
88
|
-
const r = await
|
|
100
|
+
const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
|
|
101
|
+
message: commitMsg.value.trim(),
|
|
102
|
+
...(chosen.length ? { files: chosen } : {}),
|
|
103
|
+
});
|
|
89
104
|
notify(r.text);
|
|
90
105
|
commitMsg.value = "";
|
|
91
106
|
await refresh();
|
|
92
107
|
} catch (e) {
|
|
93
|
-
notify(`commit failed: ${(e as Error).message.slice(0,
|
|
108
|
+
notify(`commit failed: ${(e as Error).message.slice(0, 140)}`);
|
|
94
109
|
} finally {
|
|
95
110
|
busy.value = "";
|
|
96
111
|
}
|
|
97
112
|
}
|
|
98
113
|
|
|
99
|
-
async function addWorktree() {
|
|
100
|
-
|
|
101
|
-
if (!
|
|
114
|
+
async function addWorktree(branch: string) {
|
|
115
|
+
wtMenu.value = false;
|
|
116
|
+
if (!devId.value || !cwd.value || !status.value?.root) return;
|
|
102
117
|
const root = status.value.root;
|
|
103
118
|
const parent = root.slice(0, root.lastIndexOf("/"));
|
|
104
119
|
const base = root.slice(root.lastIndexOf("/") + 1);
|
|
120
|
+
const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
|
|
105
121
|
busy.value = "wt";
|
|
106
122
|
try {
|
|
107
|
-
const r = await
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
});
|
|
127
|
+
notify(r.text.split("\n")[0] ?? `worktree ${target}`);
|
|
128
|
+
newBranch.value = "";
|
|
110
129
|
await refresh();
|
|
111
130
|
} catch (e) {
|
|
112
|
-
notify(`worktree failed: ${(e as Error).message.slice(0,
|
|
131
|
+
notify(`worktree failed: ${(e as Error).message.slice(0, 140)}`);
|
|
132
|
+
} finally {
|
|
133
|
+
busy.value = "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A worktree is a working copy — one click and it is a SESSION (herd flow). */
|
|
138
|
+
async function sessionOn(path: string, branch: string | null) {
|
|
139
|
+
if (!devId.value) return;
|
|
140
|
+
busy.value = "session";
|
|
141
|
+
try {
|
|
142
|
+
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];
|
|
145
|
+
if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
|
|
146
|
+
const s = await deviceClient(devId.value).createSession({
|
|
147
|
+
name: branch ?? path.slice(path.lastIndexOf("/") + 1),
|
|
148
|
+
cwd: path,
|
|
149
|
+
model: { provider: model.provider, modelId: model.id },
|
|
150
|
+
});
|
|
151
|
+
select({ deviceId: devId.value, sessionId: s.id, agentId: 1 });
|
|
152
|
+
notify(`session "${s.name}" on ${path}`);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
notify(`session failed: ${(e as Error).message.slice(0, 160)}`);
|
|
113
155
|
} finally {
|
|
114
156
|
busy.value = "";
|
|
115
157
|
}
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
async function removeWorktree(path: string) {
|
|
119
|
-
|
|
120
|
-
if (!c || !cwd.value) return;
|
|
161
|
+
if (!devId.value || !cwd.value) return;
|
|
121
162
|
if (!confirm(`Remove worktree ${path}?`)) return;
|
|
122
163
|
busy.value = "wt";
|
|
123
164
|
try {
|
|
124
|
-
await
|
|
165
|
+
await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
|
|
125
166
|
await refresh();
|
|
126
167
|
} catch (e) {
|
|
127
|
-
notify(`remove failed: ${(e as Error).message.slice(0,
|
|
168
|
+
notify(`remove failed: ${(e as Error).message.slice(0, 140)}`);
|
|
128
169
|
} finally {
|
|
129
170
|
busy.value = "";
|
|
130
171
|
}
|
|
131
172
|
}
|
|
173
|
+
const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
|
|
132
174
|
</script>
|
|
133
175
|
|
|
134
176
|
<template>
|
|
@@ -138,82 +180,131 @@ async function removeWorktree(path: string) {
|
|
|
138
180
|
<header class="git-head">
|
|
139
181
|
<strong>git</strong>
|
|
140
182
|
<code class="cwd" :title="cwd">{{ cwd }}</code>
|
|
141
|
-
<button :disabled="!!busy" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
183
|
+
<button :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
|
|
142
184
|
</header>
|
|
143
185
|
|
|
144
186
|
<p v-if="loading" class="empty">loading git…</p>
|
|
145
|
-
<p v-if="refreshing" class="muted note">refreshing…</p>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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>
|
|
189
|
+
|
|
190
|
+
<div v-if="status" :class="{ stale: !!error || !!refreshing }">
|
|
191
|
+
<p v-if="!status.repo" class="empty">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
|
|
192
|
+
|
|
193
|
+
<template v-else>
|
|
194
|
+
<!-- branch bar: PICK, never type -->
|
|
195
|
+
<div class="branch-bar">
|
|
196
|
+
<button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
|
|
197
|
+
<strong>{{ status.branch ?? "detached" }}</strong>
|
|
198
|
+
<span v-if="status.upstream" class="muted">→ {{ status.upstream }}</span>
|
|
199
|
+
<span v-if="status.ahead || status.behind" class="muted">↑{{ status.ahead ?? 0 }} ↓{{ status.behind ?? 0 }}</span>
|
|
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>
|
|
207
|
+
</button>
|
|
208
|
+
<p v-if="!(status.branches ?? []).length" class="muted pad">no local branches</p>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="row ops">
|
|
213
|
+
<button :disabled="!!busy" @click="op('fetch')">fetch</button>
|
|
214
|
+
<button :disabled="!!busy" @click="op('pull')">pull</button>
|
|
215
|
+
<button :disabled="!!busy" @click="op('log')">log</button>
|
|
216
|
+
<button :disabled="!!busy" @click="op('branch')">branches</button>
|
|
217
|
+
<button :disabled="!!busy" @click="op('diff')">diff</button>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- commit: IntelliJ — big box + file checkboxes -->
|
|
221
|
+
<section v-if="files.length" class="commit">
|
|
222
|
+
<div class="files">
|
|
223
|
+
<label class="file all">
|
|
224
|
+
<input type="checkbox" :checked="someChecked" @change="toggleAll" />
|
|
225
|
+
<span class="muted">{{ checked.size }}/{{ files.length }} changed files</span>
|
|
226
|
+
</label>
|
|
227
|
+
<label v-for="f in files" :key="f.path" class="file">
|
|
228
|
+
<input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
|
|
229
|
+
<code class="xy">{{ f.x }}{{ f.y }}</code>
|
|
230
|
+
<span class="path" :title="f.path">{{ f.path }}</span>
|
|
231
|
+
</label>
|
|
232
|
+
</div>
|
|
233
|
+
<textarea v-model="commitMsg" rows="4" placeholder="Commit message…" />
|
|
234
|
+
<button class="primary" :disabled="!!busy || !commitMsg.trim()" @click="commit">
|
|
235
|
+
Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : " -am" }}
|
|
236
|
+
</button>
|
|
237
|
+
</section>
|
|
238
|
+
|
|
239
|
+
<!-- worktrees: local + linked, one click to a session -->
|
|
240
|
+
<section class="wts">
|
|
241
|
+
<div class="wt-head">
|
|
242
|
+
<strong>Worktrees</strong>
|
|
243
|
+
<button :disabled="!!busy" @click="wtMenu = !wtMenu">+ worktree</button>
|
|
244
|
+
<div v-if="wtMenu" class="menu">
|
|
245
|
+
<button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
|
|
246
|
+
<span>{{ b.name }}</span>
|
|
247
|
+
<span class="muted">existing branch</span>
|
|
248
|
+
</button>
|
|
249
|
+
<div class="new-branch">
|
|
250
|
+
<input v-model="newBranch" placeholder="or a NEW branch name…" @keyup.enter="addWorktree(newBranch.trim() || 'wt')" />
|
|
251
|
+
<button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
<div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
|
|
256
|
+
<code :title="w.path">{{ w.path }}</code>
|
|
257
|
+
<span class="muted">{{ w.branch ?? w.head }}</span>
|
|
258
|
+
<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="12" /></button>
|
|
261
|
+
<Check v-if="w.path === status.root" :size="12" class="muted" />
|
|
262
|
+
</div>
|
|
263
|
+
</section>
|
|
264
|
+
|
|
265
|
+
<pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
|
|
266
|
+
</template>
|
|
194
267
|
</div>
|
|
195
268
|
</template>
|
|
196
269
|
</div>
|
|
197
270
|
</template>
|
|
198
271
|
|
|
199
272
|
<style scoped>
|
|
200
|
-
.git-tab { display: grid; gap:
|
|
273
|
+
.git-tab { display: grid; gap: 10px; font-size: 12px; padding: 8px 4px; }
|
|
201
274
|
.git-head { display: flex; align-items: center; gap: 8px; }
|
|
202
275
|
.git-head .cwd { font-size: 11px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
203
|
-
.
|
|
204
|
-
.
|
|
205
|
-
.
|
|
206
|
-
|
|
276
|
+
.branch-bar { position: relative; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
277
|
+
.branch-btn { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; font-size: 12px; }
|
|
278
|
+
.menu {
|
|
279
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 260px; max-height: 260px; overflow: auto;
|
|
280
|
+
background: var(--bg-2, #1c1c22); border: 1px solid var(--border, #333); border-radius: 8px; padding: 4px;
|
|
281
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
|
282
|
+
}
|
|
283
|
+
.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; }
|
|
284
|
+
.menu button:hover, .menu button.cur { background: rgb(127 127 127 / 15%); }
|
|
285
|
+
.menu .pad { padding: 6px 8px; margin: 0; }
|
|
207
286
|
.row { display: flex; gap: 6px; }
|
|
208
|
-
.
|
|
209
|
-
.
|
|
210
|
-
.
|
|
211
|
-
.
|
|
287
|
+
.ops { flex-wrap: wrap; }
|
|
288
|
+
.commit { display: grid; gap: 6px; }
|
|
289
|
+
.files { max-height: 180px; overflow: auto; border: 1px solid var(--border, #2a2a30); border-radius: 6px; padding: 2px 6px; }
|
|
290
|
+
.file { display: flex; gap: 6px; align-items: center; padding: 2px 0; }
|
|
291
|
+
.file.all { border-bottom: 1px solid var(--border, #2a2a30); }
|
|
292
|
+
.file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
293
|
+
.xy { opacity: 0.7; font-size: 10px; }
|
|
294
|
+
textarea { width: 100%; resize: vertical; min-height: 64px; font: inherit; }
|
|
295
|
+
button.primary { justify-self: start; }
|
|
296
|
+
.wts { display: grid; gap: 4px; }
|
|
297
|
+
.wt-head { position: relative; display: flex; align-items: center; gap: 8px; }
|
|
298
|
+
.wt { display: flex; gap: 8px; align-items: center; }
|
|
299
|
+
.wt code { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46%; }
|
|
300
|
+
.wt.main code { font-weight: 600; }
|
|
301
|
+
.grow { flex: 1; }
|
|
302
|
+
.new-branch { display: flex; gap: 6px; padding: 6px 8px; border-top: 1px solid var(--border, #2a2a30); }
|
|
303
|
+
.new-branch input { flex: 1; min-width: 0; }
|
|
212
304
|
.muted { opacity: 0.65; }
|
|
213
|
-
.wt-of dd { color: var(--info, #58a6ff); }
|
|
214
|
-
.out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
|
|
215
|
-
.empty { opacity: 0.75; }
|
|
216
305
|
.note { font-size: 11px; }
|
|
217
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; }
|
|
218
309
|
.error { color: var(--danger, #f85149); }
|
|
219
310
|
</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,6 +53,8 @@ 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"
|
|
@@ -169,14 +171,23 @@ export class DpiClient {
|
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
// ------------------------------------------------------------ git
|
|
172
|
-
/** Structured git status for a folder on the device (repo:false when none).
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
/** Structured git status for a folder on the device (repo:false when none).
|
|
175
|
+
* Big Windows repos can honestly take 20s+ — pass a longer timeoutMs. */
|
|
176
|
+
gitStatus(path: string, options: { timeoutMs?: number } = {}) {
|
|
177
|
+
return this.request<GitStatus>("GET", `/v1/git/status?path=${enc(path)}`, undefined, options);
|
|
175
178
|
}
|
|
176
179
|
/** One whitelisted git operation; see the device's git.ts for the op list. */
|
|
177
|
-
gitOp(
|
|
180
|
+
gitOp(
|
|
181
|
+
path: string,
|
|
182
|
+
op: GitOpName,
|
|
183
|
+
opts: { ref?: string; branch?: string; message?: string; target?: string; files?: string[] } = {},
|
|
184
|
+
) {
|
|
178
185
|
return this.request<{ text: string }>("POST", "/v1/git", { path, op, ...opts });
|
|
179
186
|
}
|
|
187
|
+
/** Many paths, one round-trip — session lists want branch-per-cwd. */
|
|
188
|
+
gitStatusBatch(paths: string[], options: { timeoutMs?: number } = {}) {
|
|
189
|
+
return this.request<Record<string, GitStatus>>("POST", "/v1/git/status-batch", { paths }, options);
|
|
190
|
+
}
|
|
180
191
|
|
|
181
192
|
// ------------------------------------------------------------ sessions
|
|
182
193
|
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,
|
|
@@ -65,10 +65,12 @@ export interface HerdRow {
|
|
|
65
65
|
kind: "group" | "session";
|
|
66
66
|
label: string;
|
|
67
67
|
sessionId?: string;
|
|
68
|
+
/** ⎇ branch of the session's cwd, when the batch fetch knows it. */
|
|
69
|
+
branch?: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Group sessions by working directory (herdr groups by repo) and apply the / filter. */
|
|
71
|
-
export function herdRows(sessions: SessionSummary[], filter = ""): HerdRow[] {
|
|
73
|
+
export function herdRows(sessions: SessionSummary[], filter = "", branchOf?: (cwd: string) => string | undefined): HerdRow[] {
|
|
72
74
|
const needle = filter.trim().toLowerCase();
|
|
73
75
|
const kept = needle
|
|
74
76
|
? sessions.filter((s) =>
|
|
@@ -87,7 +89,8 @@ export function herdRows(sessions: SessionSummary[], filter = ""): HerdRow[] {
|
|
|
87
89
|
}
|
|
88
90
|
for (const [dir, list] of groups) {
|
|
89
91
|
out.push({ kind: "group", label: dir });
|
|
90
|
-
for (const s of list)
|
|
92
|
+
for (const s of list)
|
|
93
|
+
out.push({ kind: "session", label: s.name, sessionId: s.id, branch: branchOf?.(s.cwd) });
|
|
91
94
|
}
|
|
92
95
|
return out;
|
|
93
96
|
}
|
|
@@ -136,8 +139,9 @@ export function renderHerd(
|
|
|
136
139
|
const forks = s.agents.filter((a) => a.kind === "fork").length;
|
|
137
140
|
const subs = s.agents.filter((a) => a.kind === "subagent").length;
|
|
138
141
|
const agents = `${s.agents.length}${forks ? `⑂${forks}` : ""}${subs ? `↳${subs}` : ""}`;
|
|
142
|
+
const branch = row.branch ? ` ${c.muted(`⎇ ${fit(row.branch, 10)}`)}` : "";
|
|
139
143
|
let line =
|
|
140
|
-
`${marker}${fit(selected ? c.bold(s.name) : s.name, cols.name)} ${fit(state, cols.state)}` +
|
|
144
|
+
`${marker}${fit(selected ? c.bold(s.name) : s.name, cols.name - visibleWidth(branch))}${branch} ${fit(state, cols.state)}` +
|
|
141
145
|
(showModel ? ` ${fit(c.muted(basename(s.model) || s.model), cols.model)}` : "") +
|
|
142
146
|
` ${fit(c.muted(agents), cols.agents)}`;
|
|
143
147
|
if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-durable",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|