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.
@@ -1,12 +1,24 @@
1
1
  <script setup lang="ts">
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.
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 { Check, ChevronDown, RefreshCw, X } from "lucide-vue-next";
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" | "log" | "branch" | "diff") {
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 (name === "fetch" || name === "pull") notify(`git ${name} ✓`);
98
+ if (["fetch", "pull", "push", "stash-push", "stash-pop"].includes(name)) notify(`git ${name} ✓`);
96
99
  await refresh();
97
- if (name === "fetch" || name === "pull" || name === "branch") void loadLog(true);
100
+ void loadLog(true);
98
101
  } catch (e) {
99
- out.value = `${name} failed: ${(e as Error).message.slice(0, 200)}`;
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 switchBranch(name: string) {
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-tab">
205
- <p v-if="!cwd" class="empty">No session selected — open a session to see its git.</p>
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
- <header class="git-head">
208
- <strong>git</strong>
209
- <code class="cwd" :title="cwd">{{ cwd }}</code>
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="empty">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
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 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>
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
- <p v-if="!(status.branches ?? []).length" class="muted pad">no local branches</p>
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')">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>
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: 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">
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="someChecked" @change="toggleAll" />
252
- <span class="muted">select all / none</span>
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
- <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>
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
- <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" : ""}` : "" }}
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: 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>
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="muted">loading log…</p>
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" :title="c.refs">{{ c.refs.split(",")[0] }}</span>
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="muted">no commits</p>
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
- <!-- 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>
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="12" /></button>
306
- <Check v-if="w.path === status.root" :size="12" class="muted" />
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-tab { display: grid; gap: 10px; font-size: 12px; padding: 8px 4px; }
319
- .git-head { display: flex; align-items: center; gap: 8px; }
320
- .git-head .cwd { font-size: 11px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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; }
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% + 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%);
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; 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; }
331
- .row { display: flex; gap: 6px; }
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; }
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
- .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; }
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",