dsh-worktree-switch 0.4.0 → 0.5.1

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