dsh-worktree-switch 0.4.0 → 0.5.0

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/lib/client.js CHANGED
@@ -7,6 +7,76 @@ let react = require("react");
7
7
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
9
  let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
10
+ // src/client/Badge.tsx
11
+ /**
12
+ * The persistent `⎇ branch` badge in the session header.
13
+ *
14
+ * The switch only exists on the blank-session hero, so once the first prompt is
15
+ * sent nothing on the page said whether this session runs in a copy. The badge
16
+ * fills that seat for non-blank sessions: it is quiet outside a copy, follows
17
+ * the live working directory (changing into or out of a copy), and opens the
18
+ * panel with the current copy highlighted.
19
+ * @module dsh-worktree-switch/client/Badge
20
+ */
21
+ /**
22
+ * The hover text: exactly where this session runs, and how much is uncommitted.
23
+ * @param state - the current scope answer.
24
+ * @param t - bound translate.
25
+ * @returns tooltip text, empty when there is no copy.
26
+ */
27
+ function badgeHint(state, t) {
28
+ if (state.worktree === null) return "";
29
+ return t("badge.hint", {
30
+ path: state.worktree.path,
31
+ root: state.repositoryRoot ?? "",
32
+ count: state.changedFiles
33
+ });
34
+ }
35
+ /**
36
+ * Render the session header badge.
37
+ * @param props - slot runtime props, the badge face, and the locale seat.
38
+ * @returns the badge, or null while the host is checking or outside a copy.
39
+ */
40
+ function WorktreeBadge(props) {
41
+ const { useSession, useProjection, useScope, query, open, t } = props;
42
+ const blank = useSession((session) => session.blank);
43
+ const workingDirectory = useProjection("workingDirectory");
44
+ const state = useScope((current) => current);
45
+ (0, react.useEffect)(() => {
46
+ if (blank) return;
47
+ query();
48
+ }, [
49
+ blank,
50
+ query,
51
+ workingDirectory
52
+ ]);
53
+ if (blank || state.phase !== "in" || state.worktree === null) return null;
54
+ const label = state.worktree.branch === null ? t("badge.detached") : t("badge.label", { branch: state.worktree.branch });
55
+ const hint = badgeHint(state, t);
56
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
57
+ className: "dsh-wt-badge",
58
+ "data-phase": state.phase,
59
+ "data-branch": state.worktree.branch ?? "",
60
+ "data-hint": hint,
61
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
62
+ label: hint,
63
+ side: "top",
64
+ delayMs: 300,
65
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
66
+ className: "dsh-wt-anchor",
67
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
68
+ type: "button",
69
+ className: "dsh-wt-badge-button",
70
+ "aria-label": label,
71
+ onClick: () => {
72
+ open();
73
+ },
74
+ children: label
75
+ })
76
+ })
77
+ })
78
+ });
79
+ }
10
80
  // src/client/Chip.tsx
11
81
  /**
12
82
  * The New Session switch.
@@ -45,13 +115,19 @@ function chipLabel(state, t) {
45
115
  function chipHint(state, t) {
46
116
  switch (state.phase) {
47
117
  case "checking": return t("chip.hint.checking");
48
- case "off": return t("chip.hint.off");
118
+ case "off": {
119
+ const parts = [t("chip.hint.off")];
120
+ if (state.kept !== null) parts.push(state.kept.reason === "dirty" ? t("chip.hint.keptDirty", { name: state.kept.worktree.name }) : t("chip.hint.keptNotOwned", { name: state.kept.worktree.name }));
121
+ if (state.error !== null) parts.push(state.error);
122
+ return parts.join(" ");
123
+ }
49
124
  case "creating": return t("chip.hint.creating");
50
125
  case "reverting": return t("chip.hint.reverting");
51
126
  case "ready": {
52
- const parts = [t("chip.hint.ready", {
53
- branch: state.worktree?.branch ?? "",
54
- path: state.worktree?.path ?? ""
127
+ const copy = state.worktree;
128
+ const parts = [copy === null || copy.branch === null ? t("chip.hint.detached", { path: copy?.path ?? "" }) : t("chip.hint.ready", {
129
+ branch: copy.branch,
130
+ path: copy.path
55
131
  })];
56
132
  if (state.base?.kind === "fallback") parts.push(t("chip.hint.baseFallback"));
57
133
  if (!state.intent) parts.push(t("chip.hint.intentOff", { name: state.worktree?.name ?? "" }));
@@ -102,6 +178,7 @@ function WorktreeChip(props) {
102
178
  className: "dsh-wt-chip",
103
179
  "data-phase": state.phase,
104
180
  "data-intent": state.intent ? "on" : "off",
181
+ "data-kept": state.kept === null ? "false" : state.kept.reason,
105
182
  "data-hint": hint,
106
183
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
107
184
  label: hint,
@@ -139,372 +216,58 @@ function WorktreeChip(props) {
139
216
  }) : null]
140
217
  });
141
218
  }
142
- // src/client/Panel.tsx
219
+ // src/client/Notice.tsx
143
220
  /**
144
- * The Worktrees panel and its sidebar entry.
145
- *
146
- * The page is a plain read of every workspace's pool plus one explicit,
147
- * twice-confirmed destructive action. Discarding is the only write the plugin
148
- * offers here: nothing merges, nothing is reclaimed automatically, and a copy
149
- * with uncommitted changes is refused by the host, not hidden by a disabled
150
- * button alone.
151
- * @module dsh-worktree-switch/client/Panel
221
+ * The sentence a notice shows.
222
+ * @param notice - what the leave reported.
223
+ * @param t - bound translate.
224
+ * @returns the localized explanation.
152
225
  */
153
- /** How long an armed discard stays armed, in milliseconds. */
154
- const ARM_TIMEOUT_MS = 5e3;
155
- /** Render one copy's creation time without depending on the test machine's locale. */
156
- function formatTime(milliseconds) {
157
- if (milliseconds <= 0) return "—";
158
- return new Date(milliseconds).toISOString().slice(0, 16).replace("T", " ");
159
- }
160
- /** The key that identifies one discard row. */
161
- function rowKey(group, item) {
162
- return `${group.path}\u0000${item.name}`;
163
- }
164
- /** One project's copies. */
165
- function ProjectSection(props) {
166
- const { group, state, armed, t, onArm, onDiscard } = props;
167
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
168
- className: "dsh-wt-project",
169
- "data-project": group.path,
170
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
171
- className: "dsh-wt-project-head",
172
- children: [
173
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: group.title }),
174
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
175
- className: "dsh-wt-path",
176
- children: group.path
177
- }),
178
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
179
- className: "dsh-wt-count",
180
- children: t("panel.count", { count: group.items.length })
181
- })
182
- ]
183
- }), group.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
184
- className: "dsh-wt-error",
185
- role: "alert",
186
- children: group.error
187
- }) : !group.supported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
188
- className: "dsh-wt-muted",
189
- children: t("panel.notRepository")
190
- }) : group.items.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
191
- className: "dsh-wt-muted",
192
- children: t("panel.empty")
193
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
194
- className: "dsh-wt-rows",
195
- children: group.items.map((item) => {
196
- const key = rowKey(group, item);
197
- const isArmed = armed === key;
198
- const busy = state.busy === key;
199
- const dirty = item.changedFiles > 0;
200
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
201
- className: "dsh-wt-row",
202
- "data-worktree": item.name,
203
- "data-dirty": dirty ? "true" : "false",
204
- children: [
205
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
206
- className: "dsh-wt-name",
207
- children: item.name
208
- }),
209
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
210
- className: "dsh-wt-branch",
211
- children: [
212
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
213
- className: "dsh-wt-muted",
214
- children: t("panel.branch")
215
- }),
216
- " ",
217
- item.branch
218
- ]
219
- }),
220
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
221
- className: "dsh-wt-row-path",
222
- children: item.path
223
- }),
224
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
225
- className: "dsh-wt-created",
226
- children: [
227
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
228
- className: "dsh-wt-muted",
229
- children: t("panel.created")
230
- }),
231
- " ",
232
- formatTime(item.createdAt)
233
- ]
234
- }),
235
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
236
- className: "dsh-wt-changes",
237
- "data-changes": item.changedFiles,
238
- children: dirty ? t("panel.changesCount", { count: item.changedFiles }) : t("panel.clean")
239
- }),
240
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
241
- label: dirty ? t("panel.discardDirty") : t("panel.discardHint"),
242
- side: "top",
243
- delayMs: 200,
244
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
245
- className: "dsh-wt-anchor",
246
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
247
- type: "button",
248
- className: "dsh-wt-discard",
249
- "data-armed": isArmed ? "true" : "false",
250
- disabled: dirty || busy || !group.supported,
251
- onClick: () => {
252
- if (isArmed) {
253
- onDiscard(group, item);
254
- return;
255
- }
256
- onArm(key);
257
- },
258
- children: busy ? "…" : isArmed ? t("panel.discardAgain") : t("panel.discard")
259
- })
260
- })
261
- })
262
- ]
263
- }, key);
264
- })
265
- })]
266
- });
226
+ function noticeText(notice, t) {
227
+ if (notice.kind === "failed") return t("notice.failed", { message: notice.message ?? "" });
228
+ const name = notice.worktree?.name ?? "";
229
+ return notice.reason === "dirty" ? t("notice.keptDirty", { name }) : t("notice.keptNotOwned", { name });
267
230
  }
268
231
  /**
269
- * Render the Worktrees panel.
270
- * @param props - runtime props, the panel face, and the locale seat.
271
- * @returns the page.
232
+ * Render the one-shot notice.
233
+ * @param props - overlay runtime props, the notice face, and the locale seat.
234
+ * @returns the banner, or null while nothing happened.
272
235
  */
273
- function WorktreePanel(props) {
274
- const { useWorktrees, refresh, discard, close, t } = props;
275
- const state = useWorktrees((current) => current);
276
- const [armed, setArmed] = (0, react.useState)(null);
277
- const timer = (0, react.useRef)(null);
278
- (0, react.useEffect)(() => {
279
- refresh();
280
- }, [refresh]);
281
- (0, react.useEffect)(() => () => {
282
- if (timer.current !== null) window.clearTimeout(timer.current);
283
- }, []);
284
- const arm = (key) => {
285
- if (timer.current !== null) window.clearTimeout(timer.current);
286
- setArmed(key);
287
- timer.current = window.setTimeout(() => {
288
- setArmed(null);
289
- }, ARM_TIMEOUT_MS);
290
- };
291
- const onDiscard = (group, item) => {
292
- if (timer.current !== null) window.clearTimeout(timer.current);
293
- setArmed(null);
294
- discard(group.path, item.name);
295
- };
296
- const total = state.groups.reduce((sum, group) => sum + group.items.length, 0);
236
+ function WorktreeNotice(props) {
237
+ const { useNotice, dismiss, openPanel, t } = props;
238
+ const notice = useNotice((current) => current);
239
+ if (notice === null) return null;
297
240
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
298
- className: "dsh-wt-panel",
299
- "data-loading": state.loading ? "true" : "false",
241
+ className: "dsh-wt-notice",
242
+ "data-kind": notice.kind,
243
+ "data-seq": notice.seq,
244
+ role: "status",
300
245
  children: [
301
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
302
- className: "dsh-wt-panel-head",
303
- children: [
304
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
305
- type: "button",
306
- className: "dsh-wt-back",
307
- onClick: () => {
308
- close();
309
- },
310
- children: t("panel.back")
311
- }),
312
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
313
- className: "dsh-wt-titles",
314
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("panel.title") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("panel.subtitle") })]
315
- }),
316
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
317
- className: "dsh-wt-count",
318
- children: t("panel.count", { count: total })
319
- }),
320
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
321
- type: "button",
322
- className: "dsh-wt-refresh",
323
- onClick: () => {
324
- refresh();
325
- },
326
- children: t("panel.refresh")
327
- })
328
- ]
246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
247
+ className: "dsh-wt-notice-text",
248
+ title: notice.kind === "kept" && notice.message !== null ? notice.message : void 0,
249
+ children: noticeText(notice, t)
329
250
  }),
330
- state.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
331
- className: "dsh-wt-error",
332
- role: "alert",
333
- children: t("panel.error", { message: state.error })
334
- }) : null,
335
- state.loading && state.groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
336
- className: "dsh-wt-muted",
337
- role: "status",
338
- children: t("panel.loading")
339
- }) : state.groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
340
- className: "dsh-wt-muted",
341
- children: t("panel.workspaceEmpty")
342
- }) : state.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProjectSection, {
343
- group,
344
- state,
345
- armed,
346
- t,
347
- onArm: arm,
348
- onDiscard
349
- }, group.path))
350
- ]
351
- });
352
- }
353
- /**
354
- * Render the sidebar entry that opens the panel.
355
- * @param props - sidebar owner props, the open action, and the locale seat.
356
- * @returns the icon button.
357
- */
358
- function WorktreePanelIcon(props) {
359
- const { size, active, open, t } = props;
360
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
361
- label: t("sidebar.label"),
362
- side: "right",
363
- delayMs: 300,
364
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
365
- className: "dsh-wt-anchor",
366
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
251
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
367
252
  type: "button",
368
- className: "dsh-wt-panel-icon",
369
- "data-active": active ? "true" : "false",
370
- "aria-label": t("sidebar.label"),
371
- "aria-pressed": active,
253
+ className: "dsh-wt-notice-open",
372
254
  onClick: () => {
373
- open();
255
+ openPanel();
374
256
  },
375
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
376
- width: Math.max(12, Math.round(size * .6)),
377
- height: Math.max(12, Math.round(size * .6)),
378
- viewBox: "0 0 16 16",
379
- "aria-hidden": "true",
380
- fill: "none",
381
- stroke: "currentColor",
382
- strokeWidth: "1.4",
383
- strokeLinecap: "round",
384
- children: [
385
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 2.5v7A2.5 2.5 0 0 0 6.5 12H11" }),
386
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
387
- cx: "4",
388
- cy: "2.5",
389
- r: "1.5"
390
- }),
391
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
392
- cx: "4",
393
- cy: "12.5",
394
- r: "1.5"
395
- }),
396
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
397
- cx: "11",
398
- cy: "10.5",
399
- r: "1.5"
400
- }),
401
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
402
- d: "M8.5 6.5h2.5v2",
403
- strokeDasharray: "1.6 2"
404
- })
405
- ]
406
- })
257
+ children: t("notice.openPanel")
258
+ }),
259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
260
+ type: "button",
261
+ className: "dsh-wt-notice-dismiss",
262
+ "aria-label": t("notice.dismiss"),
263
+ onClick: () => {
264
+ dismiss();
265
+ },
266
+ children: "×"
407
267
  })
408
- })
409
- });
268
+ ]
269
+ }, notice.seq);
410
270
  }
411
- // src/client/locales.ts
412
- /** English dictionary; its keys are the namespace's key domain. */
413
- const en = {
414
- "chip.label": "Worktree copy for this session",
415
- "chip.checking": "Worktree…",
416
- "chip.off": "Worktree off",
417
- "chip.creating": "Creating worktree…",
418
- "chip.reverting": "Leaving worktree…",
419
- "chip.ready": "Worktree: {name}",
420
- "chip.failed": "Worktree failed",
421
- "chip.retry": "Retry",
422
- "chip.disabled": "Worktree unavailable",
423
- "chip.hint.checking": "Checking whether this project can be isolated…",
424
- "chip.hint.off": "New sessions in this project start in the project directory.",
425
- "chip.hint.on": "This session runs in a copy of the project; the source checkout is untouched.",
426
- "chip.hint.creating": "Creating a checkout inside the project…",
427
- "chip.hint.reverting": "Moving this session back to the project directory…",
428
- "chip.hint.ready": "Branch {branch} at {path}",
429
- "chip.hint.baseFallback": "Base: local HEAD (the remote default branch could not be fetched).",
430
- "chip.hint.failed": "The copy was not created, so this session is still in the project directory.",
431
- "chip.hint.disabled": "This project is not a Git repository, so there is nothing to copy.",
432
- "chip.hint.dirty": "The source checkout has {count} uncommitted change(s); the copy starts from the last commit.",
433
- "chip.hint.intentOff": "Turning off could not leave {name}; this session still runs in it.",
434
- "chip.blocked.checking": "Checking whether this session gets a worktree…",
435
- "chip.blocked.creating": "Creating the worktree before the first prompt…",
436
- "chip.blocked.reverting": "Leaving the worktree before the first prompt…",
437
- "chip.blocked.failed": "The worktree could not be created. Retry, turn the switch off, or discard the failed copy first.",
438
- "panel.title": "Worktrees",
439
- "panel.subtitle": "Copies created by the Worktree switch: one checkout and one branch each. Discard removes both.",
440
- "panel.refresh": "Refresh",
441
- "panel.back": "Back",
442
- "panel.loading": "Reading worktrees…",
443
- "panel.empty": "No worktrees here yet.",
444
- "panel.workspaceEmpty": "No projects yet. Open a session to give this panel something to read.",
445
- "panel.error": "Could not read worktrees: {message}",
446
- "panel.notRepository": "Not a Git repository.",
447
- "panel.name": "Name",
448
- "panel.branch": "Branch",
449
- "panel.created": "Created",
450
- "panel.changes": "Changes",
451
- "panel.changesCount": "{count} uncommitted",
452
- "panel.clean": "Clean",
453
- "panel.discard": "Discard",
454
- "panel.discardAgain": "Discard now",
455
- "panel.discardHint": "Deletes the checkout and its branch.",
456
- "panel.discardDirty": "Commit or discard its changes first.",
457
- "panel.count": "{count} copies",
458
- "sidebar.label": "Worktrees"
459
- };
460
- /** Chinese dictionary: every key of {@link en}. */
461
- const zh = {
462
- "chip.label": "本会话使用 Worktree 副本",
463
- "chip.checking": "Worktree…",
464
- "chip.off": "Worktree 已关",
465
- "chip.creating": "正在创建 Worktree…",
466
- "chip.reverting": "正在离开 Worktree…",
467
- "chip.ready": "Worktree:{name}",
468
- "chip.failed": "Worktree 创建失败",
469
- "chip.retry": "重试",
470
- "chip.disabled": "Worktree 不可用",
471
- "chip.hint.checking": "正在检查这个项目能否隔离…",
472
- "chip.hint.off": "这个项目的新会话直接在项目目录里开始。",
473
- "chip.hint.on": "本会话在项目的一份副本里运行,源仓库不受影响。",
474
- "chip.hint.creating": "正在项目里创建检出…",
475
- "chip.hint.reverting": "正在把本会话切回项目目录…",
476
- "chip.hint.ready": "分支 {branch},位于 {path}",
477
- "chip.hint.baseFallback": "基点:本地 HEAD(远端未取到)。",
478
- "chip.hint.failed": "副本没有创建成功,本会话仍然在项目目录里。",
479
- "chip.hint.disabled": "这个项目不是 Git 仓库,没有可复制的内容。",
480
- "chip.hint.dirty": "源仓库有 {count} 处未提交改动;副本会从最后一次提交开始。",
481
- "chip.hint.intentOff": "关闭时没能离开 {name},本会话仍在副本里运行。",
482
- "chip.blocked.checking": "正在检查本会话是否需要 Worktree…",
483
- "chip.blocked.creating": "正在创建 Worktree,副本就绪前暂停发送…",
484
- "chip.blocked.reverting": "正在离开 Worktree,切回项目目录前暂停发送…",
485
- "chip.blocked.failed": "Worktree 创建失败。请重试、关闭开关,或先丢弃失败的副本。",
486
- "panel.title": "Worktree",
487
- "panel.subtitle": "Worktree 开关创建的副本:每份副本是一个检出加一个分支,丢弃会同时删掉两者。",
488
- "panel.refresh": "刷新",
489
- "panel.back": "返回",
490
- "panel.loading": "正在读取 Worktree…",
491
- "panel.empty": "这里还没有 Worktree。",
492
- "panel.workspaceEmpty": "还没有项目。打开一个会话,这里才有可读的内容。",
493
- "panel.error": "读取 Worktree 失败:{message}",
494
- "panel.notRepository": "不是 Git 仓库。",
495
- "panel.name": "名称",
496
- "panel.branch": "分支",
497
- "panel.created": "创建时间",
498
- "panel.changes": "改动",
499
- "panel.changesCount": "{count} 处未提交",
500
- "panel.clean": "干净",
501
- "panel.discard": "丢弃",
502
- "panel.discardAgain": "确认丢弃",
503
- "panel.discardHint": "删除检出目录和它的分支。",
504
- "panel.discardDirty": "请先提交或丢弃其中的改动。",
505
- "panel.count": "{count} 份副本",
506
- "sidebar.label": "Worktree"
507
- };
508
271
  // src/client/intent.ts
509
272
  /**
510
273
  * The remembered per-project choice: "this project always isolates" and
@@ -585,7 +348,8 @@ const INITIAL = {
585
348
  repositoryRoot: null,
586
349
  sourceDirty: 0,
587
350
  base: null,
588
- error: null
351
+ error: null,
352
+ kept: null
589
353
  };
590
354
  /** A thrown value's message, without leaking `undefined`. */
591
355
  function messageOf(error) {
@@ -594,18 +358,174 @@ function messageOf(error) {
594
358
  return text === "" ? "unknown error" : text;
595
359
  }
596
360
  /**
361
+ * Owns the one-shot notice a leave raises.
362
+ *
363
+ * Kept apart from the switch controller because it is frame-scoped and lives
364
+ * outside every session: the notice survives the session header, and unloading
365
+ * the plugin must drop it without touching any session state.
366
+ */
367
+ var NoticeController = class {
368
+ openPanel;
369
+ store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)(null);
370
+ seq = 0;
371
+ /**
372
+ * @param openPanel - opens this plugin's panel.
373
+ */
374
+ constructor(openPanel) {
375
+ this.openPanel = openPanel;
376
+ }
377
+ /** The stable face handed to the overlay component. */
378
+ face = {
379
+ hooks: { notice: this.store },
380
+ dismiss: () => {
381
+ this.store.set(null);
382
+ },
383
+ openPanel: () => {
384
+ this.store.set(null);
385
+ this.openPanel();
386
+ }
387
+ };
388
+ /**
389
+ * Show one notice, replacing whatever was showing.
390
+ * @param notice - what happened, without its sequence.
391
+ */
392
+ raise(notice) {
393
+ this.seq += 1;
394
+ this.store.set({
395
+ seq: this.seq,
396
+ ...notice
397
+ });
398
+ }
399
+ };
400
+ /** The first answer before the host has said anything. */
401
+ const EMPTY_SCOPE = {
402
+ phase: "checking",
403
+ worktree: null,
404
+ repositoryRoot: null,
405
+ changedFiles: 0
406
+ };
407
+ /**
408
+ * Answers "does this session run in a copy?" per session, once per directory.
409
+ *
410
+ * Deliberately not polled: the badge subscribes to the `workingDirectory`
411
+ * projection and asks again only when that value changes, which is also what
412
+ * makes it appear right after the switch creates a copy.
413
+ */
414
+ var BadgeController = class {
415
+ ctx;
416
+ openCopy;
417
+ bindings = /* @__PURE__ */ new Map();
418
+ lifetime = new AbortController();
419
+ /**
420
+ * @param ctx - browser Cordis context.
421
+ * @param openCopy - opens the panel with one copy path highlighted.
422
+ */
423
+ constructor(ctx, openCopy) {
424
+ this.ctx = ctx;
425
+ this.openCopy = openCopy;
426
+ }
427
+ /**
428
+ * The binding for one session, created on first use.
429
+ * @param sessionId - the session whose header badge is rendering.
430
+ * @returns the stable binding.
431
+ */
432
+ binding(sessionId) {
433
+ const key = String(sessionId);
434
+ const existing = this.bindings.get(key);
435
+ if (existing !== void 0) return existing.face;
436
+ const binding = new InternalScope(this.ctx, key, this.lifetime.signal, this.openCopy);
437
+ this.bindings.set(key, binding);
438
+ return binding.face;
439
+ }
440
+ /** Abort in-flight reads when the plugin unloads. */
441
+ dispose() {
442
+ this.lifetime.abort(/* @__PURE__ */ new Error("worktree switch unloaded"));
443
+ this.bindings.clear();
444
+ }
445
+ };
446
+ /** The concrete per-session scope reader behind one {@link BadgeBinding}. */
447
+ var InternalScope = class {
448
+ ctx;
449
+ sessionId;
450
+ signal;
451
+ openCopy;
452
+ store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)(EMPTY_SCOPE);
453
+ /** Last answer generation; a stale reply to an older directory must not win. */
454
+ generation = 0;
455
+ face;
456
+ /**
457
+ * @param ctx - browser Cordis context.
458
+ * @param sessionId - wire session identity.
459
+ * @param signal - plugin lifetime cancellation.
460
+ * @param openCopy - opens the panel with one copy path highlighted.
461
+ */
462
+ constructor(ctx, sessionId, signal, openCopy) {
463
+ this.ctx = ctx;
464
+ this.sessionId = sessionId;
465
+ this.signal = signal;
466
+ this.openCopy = openCopy;
467
+ this.face = {
468
+ hooks: { scope: this.store },
469
+ query: () => {
470
+ this.query();
471
+ },
472
+ open: () => {
473
+ const state = this.store.getSnapshot();
474
+ if (state.worktree !== null) this.openCopy(state.worktree.path);
475
+ }
476
+ };
477
+ }
478
+ /** Ask the host where this session is; a superseding call wins. */
479
+ query() {
480
+ this.run();
481
+ }
482
+ async run() {
483
+ this.generation += 1;
484
+ const generation = this.generation;
485
+ try {
486
+ const value = await callHost(this.ctx, "scope", { sessionId: this.sessionId }, this.signal);
487
+ if (this.signal.aborted || generation !== this.generation) return;
488
+ this.store.set(value.inWorktree ? {
489
+ phase: "in",
490
+ worktree: value.worktree,
491
+ repositoryRoot: value.repositoryRoot,
492
+ changedFiles: value.changedFiles
493
+ } : {
494
+ phase: "out",
495
+ worktree: null,
496
+ repositoryRoot: null,
497
+ changedFiles: 0
498
+ });
499
+ } catch {
500
+ if (this.signal.aborted || generation !== this.generation) return;
501
+ this.store.set({
502
+ phase: "failed",
503
+ worktree: null,
504
+ repositoryRoot: null,
505
+ changedFiles: 0
506
+ });
507
+ }
508
+ }
509
+ };
510
+ /**
597
511
  * One controller per plugin instance: it caches the state of every session the
598
512
  * switch has rendered for, and owns their composer blocks.
599
513
  */
600
514
  var ToggleController = class {
601
515
  ctx;
516
+ notices;
517
+ onLeftover;
602
518
  bindings = /* @__PURE__ */ new Map();
603
519
  lifetime = new AbortController();
604
520
  /**
605
521
  * @param ctx - browser Cordis context.
522
+ * @param notices - frame-level notice the switch raises when a leave keeps a copy or fails.
523
+ * @param onLeftover - asks the panel to re-count leftovers after a leave kept one.
606
524
  */
607
- constructor(ctx) {
525
+ constructor(ctx, notices, onLeftover) {
608
526
  this.ctx = ctx;
527
+ this.notices = notices;
528
+ this.onLeftover = onLeftover;
609
529
  }
610
530
  /**
611
531
  * The binding for one session, created on first use.
@@ -616,7 +536,7 @@ var ToggleController = class {
616
536
  const key = String(sessionId);
617
537
  const existing = this.bindings.get(key);
618
538
  if (existing !== void 0) return existing;
619
- const binding = new InternalBinding(this.ctx, key, this.lifetime.signal);
539
+ const binding = new InternalBinding(this.ctx, key, this.lifetime.signal, this.notices, this.onLeftover);
620
540
  this.bindings.set(key, binding.face);
621
541
  return binding.face;
622
542
  }
@@ -632,6 +552,8 @@ var InternalBinding = class {
632
552
  ctx;
633
553
  sessionId;
634
554
  signal;
555
+ notices;
556
+ onLeftover;
635
557
  store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)(INITIAL);
636
558
  started = false;
637
559
  /** The project whose remembered choice is in play; null until status answers. */
@@ -646,10 +568,12 @@ var InternalBinding = class {
646
568
  * @param sessionId - wire session identity.
647
569
  * @param signal - plugin lifetime cancellation.
648
570
  */
649
- constructor(ctx, sessionId, signal) {
571
+ constructor(ctx, sessionId, signal, notices, onLeftover) {
650
572
  this.ctx = ctx;
651
573
  this.sessionId = sessionId;
652
574
  this.signal = signal;
575
+ this.notices = notices;
576
+ this.onLeftover = onLeftover;
653
577
  this.face = {
654
578
  hooks: { toggle: this.store },
655
579
  ensure: () => {
@@ -694,7 +618,8 @@ var InternalBinding = class {
694
618
  repositoryRoot: status.repositoryRoot,
695
619
  sourceDirty: status.sourceDirty,
696
620
  base: null,
697
- error: null
621
+ error: null,
622
+ kept: null
698
623
  };
699
624
  if (status.sessionWorktree !== null) {
700
625
  this.store.set({
@@ -744,7 +669,8 @@ var InternalBinding = class {
744
669
  repositoryRoot: prepared.repositoryRoot,
745
670
  sourceDirty: prepared.sourceDirty,
746
671
  base: prepared.state === "created" ? prepared.base : null,
747
- error: null
672
+ error: null,
673
+ kept: null
748
674
  });
749
675
  } catch (error) {
750
676
  if (this.signal.aborted) return;
@@ -782,31 +708,79 @@ var InternalBinding = class {
782
708
  this.store.set({
783
709
  ...before,
784
710
  phase: "reverting",
785
- error: null
711
+ error: null,
712
+ kept: null
786
713
  });
787
714
  try {
788
- await callHost(this.ctx, "release", { sessionId: this.sessionId }, this.signal);
715
+ const value = await callHost(this.ctx, "release", { sessionId: this.sessionId }, this.signal);
789
716
  if (this.signal.aborted) return;
790
717
  const now = this.store.getSnapshot();
718
+ const kept = value.kept !== null && value.keptReason !== null ? {
719
+ worktree: value.kept,
720
+ reason: value.keptReason,
721
+ ...value.error === void 0 ? {} : { error: value.error }
722
+ } : null;
791
723
  this.store.set({
792
724
  ...now,
793
725
  phase: "off",
794
726
  intent: false,
795
727
  worktree: null,
796
728
  base: null,
797
- error: null
729
+ error: null,
730
+ kept
798
731
  });
732
+ if (kept !== null) {
733
+ this.notices.raise({
734
+ kind: "kept",
735
+ worktree: kept.worktree,
736
+ reason: kept.reason,
737
+ message: kept.error ?? null
738
+ });
739
+ this.onLeftover();
740
+ }
799
741
  } catch (error) {
800
742
  if (this.signal.aborted) return;
801
743
  const now = this.store.getSnapshot();
744
+ const message = messageOf(error);
802
745
  this.store.set({
803
746
  ...now,
804
747
  phase: now.worktree === null ? "off" : "ready",
805
748
  intent: false,
806
- error: messageOf(error)
749
+ error: message,
750
+ kept: null
751
+ });
752
+ this.notices.raise({
753
+ kind: "failed",
754
+ worktree: now.worktree,
755
+ reason: null,
756
+ message
807
757
  });
758
+ this.onLeftover();
759
+ this.recheck();
808
760
  }
809
761
  }
762
+ /**
763
+ * Re-read the session's projected state after a failure.
764
+ *
765
+ * The failing verb may still have moved the session before the delete it
766
+ * attempted failed, so the cached "ready in <copy>" face can be stale. This
767
+ * asks once which directory the host now records and shows that; it never
768
+ * creates, releases, or retries anything.
769
+ */
770
+ async recheck() {
771
+ try {
772
+ const status = await callHost(this.ctx, "status", { sessionId: this.sessionId }, this.signal);
773
+ if (this.signal.aborted || !status.supported) return;
774
+ const now = this.store.getSnapshot();
775
+ this.store.set({
776
+ ...now,
777
+ phase: status.sessionWorktree === null ? "off" : "ready",
778
+ worktree: status.sessionWorktree,
779
+ repositoryRoot: status.repositoryRoot,
780
+ sourceDirty: status.sourceDirty
781
+ });
782
+ } catch {}
783
+ }
810
784
  /** Persist and apply one switch move. */
811
785
  setIntent(next) {
812
786
  const state = this.store.getSnapshot();
@@ -817,7 +791,8 @@ var InternalBinding = class {
817
791
  this.store.set({
818
792
  ...state,
819
793
  intent: false,
820
- error: null
794
+ error: null,
795
+ kept: null
821
796
  });
822
797
  this.release();
823
798
  return;
@@ -826,7 +801,8 @@ var InternalBinding = class {
826
801
  this.store.set({
827
802
  ...state,
828
803
  intent: true,
829
- error: null
804
+ error: null,
805
+ kept: null
830
806
  });
831
807
  if (state.phase === "off" || state.phase === "failed") this.create();
832
808
  }
@@ -839,7 +815,8 @@ var InternalBinding = class {
839
815
  ...state,
840
816
  intent: true,
841
817
  phase: "off",
842
- error: null
818
+ error: null,
819
+ kept: null
843
820
  });
844
821
  this.create();
845
822
  }
@@ -851,6 +828,27 @@ var InternalBinding = class {
851
828
  }
852
829
  };
853
830
  /**
831
+ * The row key that identifies one copy's row in the panel.
832
+ *
833
+ * The panel's armed/busy state and this controller's in-flight key must agree
834
+ * character for character, so both sides build the key here.
835
+ * @param project - workspace directory the row belongs to.
836
+ * @param name - copy name.
837
+ * @returns the stable row key.
838
+ */
839
+ function copyRowKey(project, name) {
840
+ return `${project}\u0000${name}`;
841
+ }
842
+ /**
843
+ * The row key that identifies one leftover branch's row in the panel.
844
+ * @param project - workspace directory the row belongs to.
845
+ * @param branch - branch name.
846
+ * @returns the stable row key.
847
+ */
848
+ function branchRowKey(project, branch) {
849
+ return `${project}\u0000branch:${branch}`;
850
+ }
851
+ /**
854
852
  * Read every workspace's pool and discard copies from the panel.
855
853
  */
856
854
  var PanelController = class {
@@ -860,115 +858,787 @@ var PanelController = class {
860
858
  loading: true,
861
859
  groups: [],
862
860
  error: null,
863
- busy: null
861
+ busy: null,
862
+ cleanup: 0,
863
+ current: []
864
+ });
865
+ lifetime = new AbortController();
866
+ /** The read in flight, so overlapping refreshes coalesce into one more read. */
867
+ running = null;
868
+ /** A refresh arrived while one was in flight. */
869
+ again = false;
870
+ /** A copy path the badge click asked to highlight; wins over the derived ones. */
871
+ highlighted = null;
872
+ /** The failure a completed read must publish; a fresh read clears it. */
873
+ failure = null;
874
+ /**
875
+ * @param ctx - browser Cordis context.
876
+ * @param leave - called by the panel's Back action.
877
+ */
878
+ constructor(ctx, leave) {
879
+ this.ctx = ctx;
880
+ this.leave = leave;
881
+ }
882
+ /** The stable face handed to the panel component. */
883
+ face = {
884
+ hooks: { worktrees: this.store },
885
+ refresh: () => {
886
+ this.refresh();
887
+ },
888
+ discard: (project, name) => {
889
+ this.discard(project, name);
890
+ },
891
+ discardBranch: (project, branch) => {
892
+ this.discardBranch(project, branch);
893
+ },
894
+ highlight: (path) => {
895
+ this.highlight(path);
896
+ },
897
+ close: () => {
898
+ this.highlighted = null;
899
+ this.leave();
900
+ }
901
+ };
902
+ /**
903
+ * Start the count the sidebar entry shows.
904
+ *
905
+ * Runs without the panel being open, so a leftover is visible from the
906
+ * sidebar, and follows the workspace list because a project added later has
907
+ * a pool this page has never read.
908
+ * @returns disposer for the workspace subscription.
909
+ */
910
+ watch() {
911
+ this.refresh();
912
+ const workspaces = this.ctx.get("workspaces");
913
+ if (workspaces === void 0) return () => {};
914
+ return workspaces.list.subscribe(() => {
915
+ this.refresh();
916
+ });
917
+ }
918
+ /** Read every workspace's pool; projects that fail stay visible with their error. */
919
+ refresh() {
920
+ this.failure = null;
921
+ this.start();
922
+ }
923
+ /** Run one read, or queue exactly one more behind the read in flight. */
924
+ start() {
925
+ if (this.running !== null) {
926
+ this.again = true;
927
+ return;
928
+ }
929
+ this.running = this.run().finally(() => {
930
+ this.running = null;
931
+ if (this.again) {
932
+ this.again = false;
933
+ this.start();
934
+ }
935
+ });
936
+ }
937
+ /** Mark one copy current without asking the host: the badge already knows it. */
938
+ highlight(path) {
939
+ this.highlighted = path;
940
+ this.store.set({
941
+ ...this.store.getSnapshot(),
942
+ current: [path]
943
+ });
944
+ }
945
+ async run() {
946
+ const workspaces = this.ctx.get("workspaces");
947
+ if (workspaces === void 0) {
948
+ this.store.set({
949
+ loading: false,
950
+ groups: [],
951
+ error: "the workspace service is not mounted",
952
+ busy: null,
953
+ cleanup: 0,
954
+ current: []
955
+ });
956
+ return;
957
+ }
958
+ const items = workspaces.list.getSnapshot().items;
959
+ this.store.set({
960
+ ...this.store.getSnapshot(),
961
+ loading: true
962
+ });
963
+ const groups = [];
964
+ for (const workspace of items) try {
965
+ const listed = await callHost(this.ctx, "list", { path: workspace.path }, this.lifetime.signal);
966
+ groups.push(listed.supported ? {
967
+ path: workspace.path,
968
+ title: workspace.title,
969
+ supported: true,
970
+ items: listed.items,
971
+ orphans: listed.orphans,
972
+ keptCopies: listed.keptCopies,
973
+ error: null
974
+ } : {
975
+ path: workspace.path,
976
+ title: workspace.title,
977
+ supported: false,
978
+ items: [],
979
+ orphans: [],
980
+ keptCopies: 0,
981
+ error: null
982
+ });
983
+ } catch (error) {
984
+ if (this.lifetime.signal.aborted) return;
985
+ groups.push({
986
+ path: workspace.path,
987
+ title: workspace.title,
988
+ supported: true,
989
+ items: [],
990
+ orphans: [],
991
+ keptCopies: 0,
992
+ error: messageOf(error)
993
+ });
994
+ }
995
+ if (this.lifetime.signal.aborted) return;
996
+ this.store.set({
997
+ loading: false,
998
+ groups,
999
+ error: this.failure,
1000
+ busy: null,
1001
+ cleanup: groups.reduce((sum, group) => sum + group.orphans.length + group.keptCopies, 0),
1002
+ current: this.highlighted !== null ? [this.highlighted] : this.currentCopies(groups)
1003
+ });
1004
+ }
1005
+ /**
1006
+ * The copies a session is currently inside.
1007
+ *
1008
+ * Read from the Session Controller instead of the host: it is already on the
1009
+ * page, and a new host event would have to be threaded through the wire for
1010
+ * one highlight. The main-view retention count tells which session is the
1011
+ * one on screen, so archived rows never mark a copy.
1012
+ * @param groups - the groups just read.
1013
+ * @returns copy paths that session's working directory falls inside.
1014
+ */
1015
+ currentCopies(groups) {
1016
+ const sessions = this.ctx.get("sessions");
1017
+ if (sessions === void 0) return [];
1018
+ const list = sessions.list.getSnapshot();
1019
+ const found = /* @__PURE__ */ new Set();
1020
+ for (const row of Object.values(list.byId)) {
1021
+ if ((row.retainedBy.mainView ?? 0) <= 0) continue;
1022
+ const binding = sessions.binding(row.id);
1023
+ if (binding === void 0) continue;
1024
+ const directory = binding.session.projections.faceOf("workingDirectory").getSnapshot();
1025
+ if (typeof directory !== "string" || directory === "") continue;
1026
+ for (const group of groups) for (const item of group.items) if (directory === item.path || directory.startsWith(`${item.path}/`)) found.add(item.path);
1027
+ }
1028
+ return [...found];
1029
+ }
1030
+ async discard(project, name) {
1031
+ const key = copyRowKey(project, name);
1032
+ this.store.set({
1033
+ ...this.store.getSnapshot(),
1034
+ busy: key,
1035
+ error: null
1036
+ });
1037
+ try {
1038
+ await callHost(this.ctx, "discard", {
1039
+ path: project,
1040
+ name
1041
+ }, this.lifetime.signal);
1042
+ this.store.set({
1043
+ ...this.store.getSnapshot(),
1044
+ busy: null
1045
+ });
1046
+ this.refresh();
1047
+ } catch (error) {
1048
+ if (this.lifetime.signal.aborted) return;
1049
+ this.store.set({
1050
+ ...this.store.getSnapshot(),
1051
+ busy: null,
1052
+ error: messageOf(error)
1053
+ });
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Delete one leftover branch.
1058
+ *
1059
+ * A host refusal (the branch is gone already, or a copy has it checked out)
1060
+ * still re-reads the pool: the row the user clicked may be stale, and the
1061
+ * refreshed list is the honest answer either way.
1062
+ * @param project - workspace directory the branch's pool belongs to.
1063
+ * @param branch - branch to delete.
1064
+ */
1065
+ async discardBranch(project, branch) {
1066
+ const key = branchRowKey(project, branch);
1067
+ this.store.set({
1068
+ ...this.store.getSnapshot(),
1069
+ busy: key,
1070
+ error: null
1071
+ });
1072
+ try {
1073
+ await callHost(this.ctx, "discardBranch", {
1074
+ path: project,
1075
+ branch
1076
+ }, this.lifetime.signal);
1077
+ this.store.set({
1078
+ ...this.store.getSnapshot(),
1079
+ busy: null
1080
+ });
1081
+ this.refresh();
1082
+ } catch (error) {
1083
+ if (this.lifetime.signal.aborted) return;
1084
+ const message = messageOf(error);
1085
+ this.failure = message;
1086
+ this.store.set({
1087
+ ...this.store.getSnapshot(),
1088
+ busy: null,
1089
+ error: message
1090
+ });
1091
+ this.start();
1092
+ }
1093
+ }
1094
+ /** Abort in-flight reads when the plugin unloads. */
1095
+ dispose() {
1096
+ this.lifetime.abort(/* @__PURE__ */ new Error("worktree switch unloaded"));
1097
+ }
1098
+ };
1099
+ // src/client/Panel.tsx
1100
+ /**
1101
+ * The Worktrees panel and its sidebar entry.
1102
+ *
1103
+ * The page is a plain read of every workspace's pool plus explicit,
1104
+ * twice-confirmed destructive actions. Cleanup is the only write the plugin
1105
+ * offers here, and it is always explicit: a copy with uncommitted changes is
1106
+ * refused by the host, a leftover branch needs a second click, and a copy
1107
+ * whose directory was deleted by hand stays listed so its Git registration can
1108
+ * still be pruned. The current copy is marked, so the list answers "where am I"
1109
+ * as directly as possible.
1110
+ * @module dsh-worktree-switch/client/Panel
1111
+ */
1112
+ /** How long an armed destructive button stays armed, in milliseconds. */
1113
+ const ARM_TIMEOUT_MS = 5e3;
1114
+ /** Render one copy's creation time without depending on the test machine's locale. */
1115
+ function formatTime(milliseconds) {
1116
+ if (milliseconds <= 0) return "—";
1117
+ return new Date(milliseconds).toISOString().slice(0, 16).replace("T", " ");
1118
+ }
1119
+ /** One project's leftover branches, rendered only when there are any. */
1120
+ function OrphanSection(props) {
1121
+ const { group, state, armed, t, onArm, onDelete } = props;
1122
+ if (group.orphans.length === 0) return null;
1123
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1124
+ className: "dsh-wt-orphans",
1125
+ "data-project": group.path,
1126
+ "data-orphans": group.orphans.length,
1127
+ children: [
1128
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1129
+ className: "dsh-wt-orphans-head",
1130
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: t("panel.orphans") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1131
+ className: "dsh-wt-count",
1132
+ "data-count": group.orphans.length,
1133
+ children: group.orphans.length
1134
+ })]
1135
+ }),
1136
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1137
+ className: "dsh-wt-muted",
1138
+ children: t("panel.orphansHint")
1139
+ }),
1140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1141
+ className: "dsh-wt-orphan-rows",
1142
+ children: group.orphans.map((orphan) => {
1143
+ const key = branchRowKey(group.path, orphan.branch);
1144
+ const isArmed = armed === key;
1145
+ const busy = state.busy === key;
1146
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1147
+ className: "dsh-wt-orphan",
1148
+ "data-branch": orphan.branch,
1149
+ "data-commits": orphan.commits,
1150
+ children: [
1151
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1152
+ className: "dsh-wt-orphan-branch",
1153
+ children: orphan.branch
1154
+ }),
1155
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1156
+ className: "dsh-wt-orphan-commits",
1157
+ children: t("panel.orphanCommits", { count: orphan.commits })
1158
+ }),
1159
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1160
+ className: "dsh-wt-orphan-subject",
1161
+ title: orphan.subject,
1162
+ children: orphan.subject
1163
+ }),
1164
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1165
+ className: "dsh-wt-orphan-time",
1166
+ children: [
1167
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1168
+ className: "dsh-wt-muted",
1169
+ children: t("panel.orphanLatest")
1170
+ }),
1171
+ " ",
1172
+ formatTime(orphan.committedAt)
1173
+ ]
1174
+ }),
1175
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
1176
+ label: t("panel.orphanDeleteHint"),
1177
+ side: "top",
1178
+ delayMs: 200,
1179
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1180
+ className: "dsh-wt-anchor",
1181
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1182
+ type: "button",
1183
+ className: "dsh-wt-orphan-delete",
1184
+ "data-armed": isArmed ? "true" : "false",
1185
+ disabled: busy,
1186
+ onClick: () => {
1187
+ if (isArmed) {
1188
+ onDelete(group, orphan);
1189
+ return;
1190
+ }
1191
+ onArm(key);
1192
+ },
1193
+ children: busy ? "…" : isArmed ? t("panel.orphanDeleteAgain") : t("panel.orphanDelete")
1194
+ })
1195
+ })
1196
+ })
1197
+ ]
1198
+ }, key);
1199
+ })
1200
+ })
1201
+ ]
1202
+ });
1203
+ }
1204
+ /** One project's copies. */
1205
+ function ProjectSection(props) {
1206
+ const { group, state, armed, t, onArm, onDiscard, onDeleteBranch } = props;
1207
+ const current = state.current;
1208
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1209
+ className: "dsh-wt-project",
1210
+ "data-project": group.path,
1211
+ children: [
1212
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1213
+ className: "dsh-wt-project-head",
1214
+ children: [
1215
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: group.title }),
1216
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1217
+ className: "dsh-wt-path",
1218
+ children: group.path
1219
+ }),
1220
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1221
+ className: "dsh-wt-count",
1222
+ children: t("panel.count", { count: group.items.length })
1223
+ })
1224
+ ]
1225
+ }),
1226
+ group.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1227
+ className: "dsh-wt-error",
1228
+ role: "alert",
1229
+ children: group.error
1230
+ }) : !group.supported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1231
+ className: "dsh-wt-muted",
1232
+ children: t("panel.notRepository")
1233
+ }) : group.items.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1234
+ className: "dsh-wt-muted",
1235
+ children: t("panel.empty")
1236
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1237
+ className: "dsh-wt-rows",
1238
+ children: group.items.map((item) => {
1239
+ const key = copyRowKey(group.path, item.name);
1240
+ const isArmed = armed === key;
1241
+ const busy = state.busy === key;
1242
+ const dirty = item.changedFiles > 0;
1243
+ const isCurrent = current.includes(item.path);
1244
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1245
+ className: "dsh-wt-row",
1246
+ "data-worktree": item.name,
1247
+ "data-dirty": dirty ? "true" : "false",
1248
+ "data-prunable": item.prunable ? "true" : "false",
1249
+ "data-current": isCurrent ? "true" : "false",
1250
+ "aria-current": isCurrent ? "true" : void 0,
1251
+ children: [
1252
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1253
+ className: "dsh-wt-name",
1254
+ children: item.name
1255
+ }),
1256
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1257
+ className: "dsh-wt-branch",
1258
+ children: [
1259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1260
+ className: "dsh-wt-muted",
1261
+ children: t("panel.branch")
1262
+ }),
1263
+ " ",
1264
+ item.branch ?? t("panel.detached")
1265
+ ]
1266
+ }),
1267
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1268
+ className: "dsh-wt-row-path",
1269
+ children: item.path
1270
+ }),
1271
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1272
+ className: "dsh-wt-created",
1273
+ children: [
1274
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1275
+ className: "dsh-wt-muted",
1276
+ children: t("panel.created")
1277
+ }),
1278
+ " ",
1279
+ formatTime(item.createdAt)
1280
+ ]
1281
+ }),
1282
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1283
+ className: "dsh-wt-changes",
1284
+ "data-changes": item.changedFiles,
1285
+ children: dirty ? t("panel.changesCount", { count: item.changedFiles }) : t("panel.clean")
1286
+ }),
1287
+ item.prunable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1288
+ className: "dsh-wt-prunable",
1289
+ "data-prunable": "true",
1290
+ children: t("panel.prunable")
1291
+ }) : null,
1292
+ isCurrent ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1293
+ className: "dsh-wt-current",
1294
+ children: t("panel.current")
1295
+ }) : null,
1296
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
1297
+ label: dirty ? t("panel.discardDirty") : item.prunable ? t("panel.prunableHint") : t("panel.discardHint"),
1298
+ side: "top",
1299
+ delayMs: 200,
1300
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1301
+ className: "dsh-wt-anchor",
1302
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1303
+ type: "button",
1304
+ className: "dsh-wt-discard",
1305
+ "data-armed": isArmed ? "true" : "false",
1306
+ disabled: dirty || busy || !group.supported,
1307
+ onClick: () => {
1308
+ if (isArmed) {
1309
+ onDiscard(group, item);
1310
+ return;
1311
+ }
1312
+ onArm(key);
1313
+ },
1314
+ children: busy ? "…" : isArmed ? t("panel.discardAgain") : t("panel.discard")
1315
+ })
1316
+ })
1317
+ })
1318
+ ]
1319
+ }, key);
1320
+ })
1321
+ }),
1322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanSection, {
1323
+ group,
1324
+ state,
1325
+ armed,
1326
+ t,
1327
+ onArm,
1328
+ onDelete: onDeleteBranch
1329
+ })
1330
+ ]
1331
+ });
1332
+ }
1333
+ /**
1334
+ * Render the Worktrees panel.
1335
+ * @param props - runtime props, the panel face, and the locale seat.
1336
+ * @returns the page.
1337
+ */
1338
+ function WorktreePanel(props) {
1339
+ const { useWorktrees, refresh, discard, discardBranch, close, t } = props;
1340
+ const state = useWorktrees((current) => current);
1341
+ const [armed, setArmed] = (0, react.useState)(null);
1342
+ const timer = (0, react.useRef)(null);
1343
+ (0, react.useEffect)(() => {
1344
+ refresh();
1345
+ }, [refresh]);
1346
+ (0, react.useEffect)(() => () => {
1347
+ if (timer.current !== null) window.clearTimeout(timer.current);
1348
+ }, []);
1349
+ const arm = (key) => {
1350
+ if (timer.current !== null) window.clearTimeout(timer.current);
1351
+ setArmed(key);
1352
+ timer.current = window.setTimeout(() => {
1353
+ setArmed(null);
1354
+ }, ARM_TIMEOUT_MS);
1355
+ };
1356
+ const onDiscard = (group, item) => {
1357
+ if (timer.current !== null) window.clearTimeout(timer.current);
1358
+ setArmed(null);
1359
+ discard(group.path, item.name);
1360
+ };
1361
+ const onDeleteBranch = (group, orphan) => {
1362
+ if (timer.current !== null) window.clearTimeout(timer.current);
1363
+ setArmed(null);
1364
+ discardBranch(group.path, orphan.branch);
1365
+ };
1366
+ const total = state.groups.reduce((sum, group) => sum + group.items.length, 0);
1367
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1368
+ className: "dsh-wt-panel",
1369
+ "data-loading": state.loading ? "true" : "false",
1370
+ children: [
1371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1372
+ className: "dsh-wt-panel-head",
1373
+ children: [
1374
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1375
+ type: "button",
1376
+ className: "dsh-wt-back",
1377
+ onClick: () => {
1378
+ close();
1379
+ },
1380
+ children: t("panel.back")
1381
+ }),
1382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1383
+ className: "dsh-wt-titles",
1384
+ children: [
1385
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("panel.title") }),
1386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("panel.subtitle") }),
1387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1388
+ className: "dsh-wt-muted",
1389
+ "data-cleanup-hint": true,
1390
+ children: t("panel.cleanupHint")
1391
+ })
1392
+ ]
1393
+ }),
1394
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1395
+ className: "dsh-wt-count",
1396
+ children: t("panel.count", { count: total })
1397
+ }),
1398
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1399
+ type: "button",
1400
+ className: "dsh-wt-refresh",
1401
+ onClick: () => {
1402
+ refresh();
1403
+ },
1404
+ children: t("panel.refresh")
1405
+ })
1406
+ ]
1407
+ }),
1408
+ state.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1409
+ className: "dsh-wt-error",
1410
+ role: "alert",
1411
+ children: t("panel.error", { message: state.error })
1412
+ }) : null,
1413
+ state.loading && state.groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1414
+ className: "dsh-wt-muted",
1415
+ role: "status",
1416
+ children: t("panel.loading")
1417
+ }) : state.groups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1418
+ className: "dsh-wt-muted",
1419
+ children: t("panel.workspaceEmpty")
1420
+ }) : state.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ProjectSection, {
1421
+ group,
1422
+ state,
1423
+ armed,
1424
+ t,
1425
+ onArm: arm,
1426
+ onDiscard,
1427
+ onDeleteBranch
1428
+ }, group.path))
1429
+ ]
1430
+ });
1431
+ }
1432
+ /**
1433
+ * Render the sidebar entry that opens the panel.
1434
+ *
1435
+ * The numeric count is the plugin's only ambient signal outside the header: it
1436
+ * appears when a pool still holds a leftover branch or a kept copy, and stays
1437
+ * silent otherwise.
1438
+ * @param props - sidebar owner props, the panel snapshot, the open action, and the locale seat.
1439
+ * @returns the icon button.
1440
+ */
1441
+ function WorktreePanelIcon(props) {
1442
+ const { size, active, useWorktrees, open, t } = props;
1443
+ const cleanup = useWorktrees((state) => state.cleanup);
1444
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
1445
+ label: t("sidebar.label"),
1446
+ side: "right",
1447
+ delayMs: 300,
1448
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1449
+ className: "dsh-wt-anchor",
1450
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1451
+ type: "button",
1452
+ className: "dsh-wt-panel-icon",
1453
+ "data-active": active ? "true" : "false",
1454
+ "data-cleanup": cleanup,
1455
+ "aria-label": t("sidebar.label"),
1456
+ "aria-pressed": active,
1457
+ onClick: () => {
1458
+ open();
1459
+ },
1460
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1461
+ width: Math.max(12, Math.round(size * .6)),
1462
+ height: Math.max(12, Math.round(size * .6)),
1463
+ viewBox: "0 0 16 16",
1464
+ "aria-hidden": "true",
1465
+ fill: "none",
1466
+ stroke: "currentColor",
1467
+ strokeWidth: "1.4",
1468
+ strokeLinecap: "round",
1469
+ children: [
1470
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 2.5v7A2.5 2.5 0 0 0 6.5 12H11" }),
1471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1472
+ cx: "4",
1473
+ cy: "2.5",
1474
+ r: "1.5"
1475
+ }),
1476
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1477
+ cx: "4",
1478
+ cy: "12.5",
1479
+ r: "1.5"
1480
+ }),
1481
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1482
+ cx: "11",
1483
+ cy: "10.5",
1484
+ r: "1.5"
1485
+ }),
1486
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1487
+ d: "M8.5 6.5h2.5v2",
1488
+ strokeDasharray: "1.6 2"
1489
+ })
1490
+ ]
1491
+ }), cleanup > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1492
+ className: "dsh-wt-panel-icon-count",
1493
+ "aria-hidden": "true",
1494
+ children: cleanup > 99 ? "99+" : cleanup
1495
+ }) : null]
1496
+ })
1497
+ })
864
1498
  });
865
- lifetime = new AbortController();
866
- /**
867
- * @param ctx - browser Cordis context.
868
- * @param leave - called by the panel's Back action.
869
- */
870
- constructor(ctx, leave) {
871
- this.ctx = ctx;
872
- this.leave = leave;
873
- }
874
- /** The stable face handed to the panel component. */
875
- face = {
876
- hooks: { worktrees: this.store },
877
- refresh: () => {
878
- this.refresh();
879
- },
880
- discard: (project, name) => {
881
- this.discard(project, name);
882
- },
883
- close: () => {
884
- this.leave();
885
- }
886
- };
887
- /** Read every workspace's pool; projects that fail stay visible with their error. */
888
- refresh() {
889
- this.run();
890
- }
891
- async run() {
892
- const workspaces = this.ctx.get("workspaces");
893
- if (workspaces === void 0) {
894
- this.store.set({
895
- loading: false,
896
- groups: [],
897
- error: "the workspace service is not mounted",
898
- busy: null
899
- });
900
- return;
901
- }
902
- const items = workspaces.list.getSnapshot().items;
903
- this.store.set({
904
- ...this.store.getSnapshot(),
905
- loading: true,
906
- error: null
907
- });
908
- const groups = [];
909
- for (const workspace of items) try {
910
- const listed = await callHost(this.ctx, "list", { path: workspace.path }, this.lifetime.signal);
911
- groups.push(listed.supported ? {
912
- path: workspace.path,
913
- title: workspace.title,
914
- supported: true,
915
- items: listed.items,
916
- error: null
917
- } : {
918
- path: workspace.path,
919
- title: workspace.title,
920
- supported: false,
921
- items: [],
922
- error: null
923
- });
924
- } catch (error) {
925
- if (this.lifetime.signal.aborted) return;
926
- groups.push({
927
- path: workspace.path,
928
- title: workspace.title,
929
- supported: true,
930
- items: [],
931
- error: messageOf(error)
932
- });
933
- }
934
- if (this.lifetime.signal.aborted) return;
935
- this.store.set({
936
- loading: false,
937
- groups,
938
- error: null,
939
- busy: null
940
- });
941
- }
942
- async discard(project, name) {
943
- const key = `${project}\u0000${name}`;
944
- this.store.set({
945
- ...this.store.getSnapshot(),
946
- busy: key,
947
- error: null
948
- });
949
- try {
950
- await callHost(this.ctx, "discard", {
951
- path: project,
952
- name
953
- }, this.lifetime.signal);
954
- this.store.set({
955
- ...this.store.getSnapshot(),
956
- busy: null
957
- });
958
- await this.run();
959
- } catch (error) {
960
- if (this.lifetime.signal.aborted) return;
961
- this.store.set({
962
- ...this.store.getSnapshot(),
963
- busy: null,
964
- error: messageOf(error)
965
- });
966
- }
967
- }
968
- /** Abort in-flight reads when the plugin unloads. */
969
- dispose() {
970
- this.lifetime.abort(/* @__PURE__ */ new Error("worktree switch unloaded"));
971
- }
1499
+ }
1500
+ // src/client/locales.ts
1501
+ /** English dictionary; its keys are the namespace's key domain. */
1502
+ const en = {
1503
+ "chip.label": "Worktree copy for this session",
1504
+ "chip.checking": "Worktree…",
1505
+ "chip.off": "Worktree off",
1506
+ "chip.creating": "Creating worktree…",
1507
+ "chip.reverting": "Leaving worktree…",
1508
+ "chip.ready": "Worktree: {name}",
1509
+ "chip.failed": "Worktree failed",
1510
+ "chip.retry": "Retry",
1511
+ "chip.disabled": "Worktree unavailable",
1512
+ "chip.hint.checking": "Checking whether this project can be isolated…",
1513
+ "chip.hint.off": "New sessions in this project start in the project directory.",
1514
+ "chip.hint.on": "This session runs in a copy of the project; the source checkout is untouched.",
1515
+ "chip.hint.creating": "Creating a checkout inside the project…",
1516
+ "chip.hint.reverting": "Moving this session back to the project directory…",
1517
+ "chip.hint.ready": "Branch {branch} at {path}",
1518
+ "chip.hint.detached": "A detached copy at {path}; it has no branch.",
1519
+ "chip.hint.baseFallback": "Base: local HEAD (the remote default branch could not be fetched).",
1520
+ "chip.hint.failed": "The copy was not created, so this session is still in the project directory.",
1521
+ "chip.hint.disabled": "This project is not a Git repository, so there is nothing to copy.",
1522
+ "chip.hint.dirty": "The source checkout has {count} uncommitted change(s); the copy starts from the last commit.",
1523
+ "chip.hint.intentOff": "Turning off could not leave {name}; this session still runs in it.",
1524
+ "chip.hint.keptDirty": "The copy {name} was kept when the switch turned off: it has uncommitted changes or commits of its own.",
1525
+ "chip.hint.keptNotOwned": "The copy {name} was kept when the switch turned off: the switch cannot prove it created this copy, so it never deletes it.",
1526
+ "chip.blocked.checking": "Checking whether this session gets a worktree…",
1527
+ "chip.blocked.creating": "Creating the worktree before the first prompt…",
1528
+ "chip.blocked.reverting": "Leaving the worktree before the first prompt…",
1529
+ "chip.blocked.failed": "The worktree could not be created. Retry, turn the switch off, or discard the failed copy first.",
1530
+ "panel.title": "Worktrees",
1531
+ "panel.subtitle": "Copies created by the Worktree switch, plus leftover branches no copy checks out. Both are removed only by an explicit action here.",
1532
+ "panel.refresh": "Refresh",
1533
+ "panel.back": "Back",
1534
+ "panel.loading": "Reading worktrees…",
1535
+ "panel.empty": "No worktrees here yet.",
1536
+ "panel.workspaceEmpty": "No projects yet. Open a session to give this panel something to read.",
1537
+ "panel.error": "Could not read worktrees: {message}",
1538
+ "panel.notRepository": "Not a Git repository.",
1539
+ "panel.name": "Name",
1540
+ "panel.branch": "Branch",
1541
+ "panel.detached": "detached",
1542
+ "panel.created": "Created",
1543
+ "panel.changes": "Changes",
1544
+ "panel.changesCount": "{count} uncommitted",
1545
+ "panel.clean": "Clean",
1546
+ "panel.discard": "Discard",
1547
+ "panel.discardAgain": "Discard now",
1548
+ "panel.discardHint": "Deletes the checkout and its branch.",
1549
+ "panel.discardDirty": "Commit or discard its changes first.",
1550
+ "panel.cleanupHint": "Cleanup count: leftover branches plus copies this host kept when a switch was turned off. A host restart forgets the latter, so the count then covers leftovers only.",
1551
+ "panel.prunable": "Directory missing",
1552
+ "panel.prunableHint": "The directory is gone; discarding removes the leftover Git registration and its branch.",
1553
+ "panel.current": "current copy",
1554
+ "panel.orphans": "Leftover branches",
1555
+ "panel.orphansHint": "Branches from a finished isolation that no copy checks out. Deleting one removes only the branch.",
1556
+ "panel.orphanCommits": "{count} commit(s) the source checkout does not have",
1557
+ "panel.orphanLatest": "Latest",
1558
+ "panel.orphanDelete": "Delete branch",
1559
+ "panel.orphanDeleteAgain": "Delete branch now",
1560
+ "panel.orphanDeleteHint": "Deletes this branch. No copy checks it out.",
1561
+ "panel.count": "{count} copies",
1562
+ "badge.label": "⎇ {branch}",
1563
+ "badge.detached": "⎇ detached copy",
1564
+ "badge.hint": "This session runs in a copy of the project. Directory: {path}. Repository: {root}. Uncommitted files: {count}.",
1565
+ "notice.keptDirty": "The Worktree switch is off. The copy {name} was kept because it has uncommitted changes or commits of its own; delete it from the Worktrees panel when you are done.",
1566
+ "notice.keptNotOwned": "The Worktree switch is off. The copy {name} was kept because the switch cannot prove it created it, so it never deletes it.",
1567
+ "notice.failed": "The Worktree switch is off, but leaving the copy failed: {message}",
1568
+ "notice.openPanel": "Open panel",
1569
+ "notice.dismiss": "Dismiss",
1570
+ "sidebar.label": "Worktrees"
1571
+ };
1572
+ /** Chinese dictionary: every key of {@link en}. */
1573
+ const zh = {
1574
+ "chip.label": "本会话使用 Worktree 副本",
1575
+ "chip.checking": "Worktree…",
1576
+ "chip.off": "Worktree 已关",
1577
+ "chip.creating": "正在创建 Worktree…",
1578
+ "chip.reverting": "正在离开 Worktree…",
1579
+ "chip.ready": "Worktree:{name}",
1580
+ "chip.failed": "Worktree 创建失败",
1581
+ "chip.retry": "重试",
1582
+ "chip.disabled": "Worktree 不可用",
1583
+ "chip.hint.checking": "正在检查这个项目能否隔离…",
1584
+ "chip.hint.off": "这个项目的新会话直接在项目目录里开始。",
1585
+ "chip.hint.on": "本会话在项目的一份副本里运行,源仓库不受影响。",
1586
+ "chip.hint.creating": "正在项目里创建检出…",
1587
+ "chip.hint.reverting": "正在把本会话切回项目目录…",
1588
+ "chip.hint.ready": "分支 {branch},位于 {path}",
1589
+ "chip.hint.detached": "{path} 处是一份分离 HEAD 的副本,没有分支。",
1590
+ "chip.hint.baseFallback": "基点:本地 HEAD(远端未取到)。",
1591
+ "chip.hint.failed": "副本没有创建成功,本会话仍然在项目目录里。",
1592
+ "chip.hint.disabled": "这个项目不是 Git 仓库,没有可复制的内容。",
1593
+ "chip.hint.dirty": "源仓库有 {count} 处未提交改动;副本会从最后一次提交开始。",
1594
+ "chip.hint.intentOff": "关闭时没能离开 {name},本会话仍在副本里运行。",
1595
+ "chip.hint.keptDirty": "关闭开关时保留了副本 {name}:它有自己的提交或未提交改动。",
1596
+ "chip.hint.keptNotOwned": "关闭开关时保留了副本 {name}:无法证明这份副本是开关创建的,所以不会删除它。",
1597
+ "chip.blocked.checking": "正在检查本会话是否需要 Worktree…",
1598
+ "chip.blocked.creating": "正在创建 Worktree,副本就绪前暂停发送…",
1599
+ "chip.blocked.reverting": "正在离开 Worktree,切回项目目录前暂停发送…",
1600
+ "chip.blocked.failed": "Worktree 创建失败。请重试、关闭开关,或先丢弃失败的副本。",
1601
+ "panel.title": "Worktree",
1602
+ "panel.subtitle": "Worktree 开关创建的副本,以及没有副本检出的遗留分支。两者都只在这里显式操作时才会被删除。",
1603
+ "panel.refresh": "刷新",
1604
+ "panel.back": "返回",
1605
+ "panel.loading": "正在读取 Worktree…",
1606
+ "panel.empty": "这里还没有 Worktree。",
1607
+ "panel.workspaceEmpty": "还没有项目。打开一个会话,这里才有可读的内容。",
1608
+ "panel.error": "读取 Worktree 失败:{message}",
1609
+ "panel.notRepository": "不是 Git 仓库。",
1610
+ "panel.name": "名称",
1611
+ "panel.branch": "分支",
1612
+ "panel.detached": "分离 HEAD",
1613
+ "panel.created": "创建时间",
1614
+ "panel.changes": "改动",
1615
+ "panel.changesCount": "{count} 处未提交",
1616
+ "panel.clean": "干净",
1617
+ "panel.discard": "丢弃",
1618
+ "panel.discardAgain": "确认丢弃",
1619
+ "panel.discardHint": "删除检出目录和它的分支。",
1620
+ "panel.discardDirty": "请先提交或丢弃其中的改动。",
1621
+ "panel.cleanupHint": "清理计数 = 遗留分支 + 本次宿主运行时开关保留的副本;宿主重启后不可知,只剩遗留分支可数。",
1622
+ "panel.prunable": "目录已丢失",
1623
+ "panel.prunableHint": "目录已经不在了;丢弃会清掉残留的 Git 注册和它的分支。",
1624
+ "panel.current": "当前副本",
1625
+ "panel.orphans": "遗留分支",
1626
+ "panel.orphansHint": "一次隔离结束后没有删掉、也没有副本检出的分支。删除只移除这个分支。",
1627
+ "panel.orphanCommits": "源检出没有的 {count} 个提交",
1628
+ "panel.orphanLatest": "最近提交",
1629
+ "panel.orphanDelete": "删除分支",
1630
+ "panel.orphanDeleteAgain": "确认删除分支",
1631
+ "panel.orphanDeleteHint": "删除这个分支。没有副本检出它。",
1632
+ "panel.count": "{count} 份副本",
1633
+ "badge.label": "⎇ {branch}",
1634
+ "badge.detached": "⎇ 分离副本",
1635
+ "badge.hint": "本会话在项目的一份副本里运行。目录:{path}。仓库:{root}。未提交文件:{count}。",
1636
+ "notice.keptDirty": "Worktree 开关已关闭。副本 {name} 被保留,因为它有自己的提交或未提交改动;用完请在 Worktree 面板里删除。",
1637
+ "notice.keptNotOwned": "Worktree 开关已关闭。副本 {name} 被保留,因为无法证明它是开关创建的,插件不会删除它。",
1638
+ "notice.failed": "Worktree 开关已关闭,但离开副本失败:{message}",
1639
+ "notice.openPanel": "打开面板",
1640
+ "notice.dismiss": "关闭提示",
1641
+ "sidebar.label": "Worktree"
972
1642
  };
973
1643
  // src/client/styles.ts
974
1644
  /**
@@ -1036,12 +1706,14 @@ const CSS = `
1036
1706
  .dsh-wt-path, .dsh-wt-row-path { font-size: 11px; opacity: .64; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1037
1707
  .dsh-wt-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
1038
1708
  .dsh-wt-row {
1039
- display: grid; align-items: center; gap: 12px;
1040
- grid-template-columns: minmax(120px, 1.2fr) minmax(120px, 1fr) minmax(160px, 1.6fr) auto auto auto;
1709
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
1041
1710
  padding: 8px 12px; border-radius: 8px;
1042
1711
  border: 1px solid color-mix(in oklab, currentColor 12%, transparent);
1043
1712
  }
1044
- .dsh-wt-name { font-weight: 600; }
1713
+ .dsh-wt-name { font-weight: 600; min-width: 120px; }
1714
+ .dsh-wt-branch { min-width: 120px; }
1715
+ .dsh-wt-row-path { flex: 1 1 180px; min-width: 140px; }
1716
+ .dsh-wt-row .dsh-wt-anchor { margin-left: auto; }
1045
1717
  .dsh-wt-branch, .dsh-wt-created, .dsh-wt-changes { font-size: 12px; }
1046
1718
  .dsh-wt-changes[data-changes]:not([data-changes="0"]) { color: #bf8700; }
1047
1719
  .dsh-wt-muted { opacity: .64; font-size: 12px; }
@@ -1053,6 +1725,81 @@ const CSS = `
1053
1725
  }
1054
1726
  .dsh-wt-panel-icon:hover { opacity: 1; background: color-mix(in oklab, currentColor 8%, transparent); }
1055
1727
  .dsh-wt-panel-icon[data-active="true"] { opacity: 1; background: color-mix(in oklab, currentColor 12%, transparent); }
1728
+ .dsh-wt-panel-icon { position: relative; }
1729
+ .dsh-wt-panel-icon-count {
1730
+ position: absolute; top: -2px; right: -2px; min-width: 14px; height: 14px; padding: 0 3px;
1731
+ border-radius: 999px; background: #bf8700; color: #fff; font-size: 9px; line-height: 14px;
1732
+ font-weight: 600; text-align: center; pointer-events: none;
1733
+ }
1734
+ .dsh-wt-badge { display: inline-flex; align-items: center; }
1735
+ .dsh-wt-badge-button {
1736
+ display: inline-flex; align-items: center; gap: 4px;
1737
+ padding: 1px 8px; border-radius: 999px;
1738
+ border: 1px solid color-mix(in oklab, currentColor 20%, transparent);
1739
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
1740
+ line-height: 18px; cursor: pointer; white-space: nowrap;
1741
+ max-width: 220px; overflow: hidden; text-overflow: ellipsis;
1742
+ }
1743
+ .dsh-wt-badge-button:hover { border-color: color-mix(in oklab, currentColor 42%, transparent); }
1744
+ .dsh-wt-badge-button:focus-visible { outline: 2px solid color-mix(in oklab, currentColor 45%, transparent); outline-offset: 1px; }
1745
+ .dsh-wt-notice {
1746
+ position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%);
1747
+ display: flex; align-items: center; gap: 12px; max-width: min(720px, calc(100vw - 40px));
1748
+ padding: 10px 14px; border-radius: 10px; pointer-events: auto;
1749
+ border: 1px solid color-mix(in oklab, #bf8700 55%, transparent);
1750
+ background: color-mix(in oklab, #1f2328 88%, transparent); color: #f0f6fc;
1751
+ font: inherit; font-size: 12px; line-height: 18px;
1752
+ box-shadow: 0 6px 20px color-mix(in oklab, #000 32%, transparent);
1753
+ }
1754
+ .dsh-wt-notice[data-kind="failed"] { border-color: color-mix(in oklab, #e5534b 60%, transparent); }
1755
+ .dsh-wt-notice-text { flex: 1; min-width: 0; }
1756
+ .dsh-wt-notice-open {
1757
+ border: 1px solid color-mix(in oklab, currentColor 40%, transparent);
1758
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
1759
+ border-radius: 6px; padding: 3px 10px; cursor: pointer; white-space: nowrap;
1760
+ }
1761
+ .dsh-wt-notice-open:hover { border-color: currentColor; }
1762
+ .dsh-wt-notice-dismiss {
1763
+ border: none; background: transparent; color: inherit; font: inherit; font-size: 15px;
1764
+ line-height: 1; cursor: pointer; padding: 2px 4px; opacity: .72;
1765
+ }
1766
+ .dsh-wt-notice-dismiss:hover { opacity: 1; }
1767
+ .dsh-wt-row[data-current="true"] {
1768
+ border-color: color-mix(in oklab, #2da44e 55%, transparent);
1769
+ background: color-mix(in oklab, #2da44e 8%, transparent);
1770
+ }
1771
+ .dsh-wt-current {
1772
+ font-size: 11px; font-weight: 600; color: #2da44e;
1773
+ border: 1px solid color-mix(in oklab, #2da44e 45%, transparent);
1774
+ border-radius: 999px; padding: 0 8px; white-space: nowrap;
1775
+ }
1776
+ .dsh-wt-prunable {
1777
+ font-size: 11px; color: #bf8700; white-space: nowrap;
1778
+ }
1779
+ .dsh-wt-orphans { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
1780
+ .dsh-wt-orphans-head { display: flex; align-items: baseline; gap: 8px; }
1781
+ .dsh-wt-orphans-head h4 { margin: 0; font-size: 12px; font-weight: 600; opacity: .86; }
1782
+ .dsh-wt-orphan-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
1783
+ .dsh-wt-orphan {
1784
+ display: grid; align-items: center; gap: 12px;
1785
+ grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) minmax(160px, 1.6fr) auto auto;
1786
+ padding: 6px 12px; border-radius: 8px;
1787
+ border: 1px dashed color-mix(in oklab, currentColor 18%, transparent);
1788
+ }
1789
+ .dsh-wt-orphan-branch { font-size: 12px; font-weight: 600; }
1790
+ .dsh-wt-orphan-commits, .dsh-wt-orphan-time { font-size: 12px; opacity: .78; }
1791
+ .dsh-wt-orphan-subject { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1792
+ .dsh-wt-orphan-delete {
1793
+ border: 1px solid color-mix(in oklab, currentColor 24%, transparent);
1794
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
1795
+ border-radius: 6px; padding: 4px 10px; cursor: pointer; white-space: nowrap;
1796
+ }
1797
+ .dsh-wt-orphan-delete:hover:not(:disabled) { border-color: color-mix(in oklab, currentColor 44%, transparent); }
1798
+ .dsh-wt-orphan-delete:disabled { opacity: .6; cursor: default; }
1799
+ .dsh-wt-orphan-delete[data-armed="true"] {
1800
+ border-color: color-mix(in oklab, #e5534b 60%, transparent);
1801
+ color: #e5534b;
1802
+ }
1056
1803
  `;
1057
1804
  /**
1058
1805
  * Install the stylesheet if this page has not installed it yet.
@@ -1080,13 +1827,15 @@ const PANEL_ID = "dsh-worktree-switch";
1080
1827
  * Client services this plugin cannot work without.
1081
1828
  *
1082
1829
  * `conversation` is how the switch stops a prompt while a copy it promised is
1083
- * not ready; `workspaces` is how the panel finds the projects to list.
1830
+ * not ready; `workspaces` is how the panel finds the projects to list;
1831
+ * `sessions` is how the panel derives which copy the visible session is in.
1084
1832
  */
1085
1833
  const inject = [
1086
1834
  "conversation",
1087
1835
  "connection",
1088
1836
  "layout",
1089
1837
  "locale",
1838
+ "sessions",
1090
1839
  "slots",
1091
1840
  "workspaces"
1092
1841
  ];
@@ -1101,13 +1850,27 @@ function apply(ctx) {
1101
1850
  }), "dsh-worktree-switch: dictionaries");
1102
1851
  ctx.effect(installStyles, "dsh-worktree-switch: styles");
1103
1852
  const t = ctx.locale.bind(NS);
1104
- const toggles = new ToggleController(ctx);
1853
+ const notices = new NoticeController(() => {
1854
+ ctx.layout.selectPanel(PANEL_ID);
1855
+ });
1105
1856
  const panels = new PanelController(ctx, () => {
1106
1857
  ctx.layout.selectPanel(null);
1107
1858
  });
1108
- ctx.effect(() => () => {
1109
- toggles.dispose();
1110
- panels.dispose();
1859
+ const toggles = new ToggleController(ctx, notices, () => {
1860
+ panels.refresh();
1861
+ });
1862
+ const badges = new BadgeController(ctx, (path) => {
1863
+ panels.highlight(path);
1864
+ ctx.layout.selectPanel(PANEL_ID);
1865
+ });
1866
+ ctx.effect(() => {
1867
+ const unwatch = panels.watch();
1868
+ return () => {
1869
+ unwatch();
1870
+ toggles.dispose();
1871
+ panels.dispose();
1872
+ badges.dispose();
1873
+ };
1111
1874
  }, "dsh-worktree-switch: controllers");
1112
1875
  ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
1113
1876
  name: "conversation.input.left",
@@ -1128,10 +1891,27 @@ function apply(ctx) {
1128
1891
  order: 30,
1129
1892
  locale: NS,
1130
1893
  label: () => t("sidebar.label"),
1131
- inject: () => ({ open: () => {
1132
- ctx.layout.selectPanel(PANEL_ID);
1133
- } })
1894
+ inject: () => ({
1895
+ hooks: panels.face.hooks,
1896
+ open: () => {
1897
+ ctx.layout.selectPanel(PANEL_ID);
1898
+ }
1899
+ })
1134
1900
  }, WorktreePanelIcon));
1901
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
1902
+ name: "conversation.session.header.utilities",
1903
+ id: "dsh-worktree-switch.badge",
1904
+ order: 60,
1905
+ locale: NS,
1906
+ inject: (sessionId) => badges.binding(sessionId)
1907
+ }, WorktreeBadge));
1908
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
1909
+ name: "shell.overlay",
1910
+ id: "dsh-worktree-switch.notice",
1911
+ order: 20,
1912
+ locale: NS,
1913
+ inject: () => notices.face
1914
+ }, WorktreeNotice));
1135
1915
  }
1136
1916
  exports.NS = NS;
1137
1917
  exports.PANEL_ID = PANEL_ID;