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/README.md +90 -20
- package/lib/client.js +1537 -644
- package/lib/index.js +492 -103
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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/
|
|
10
|
+
// src/client/Badge.tsx
|
|
11
11
|
/**
|
|
12
|
-
* The
|
|
12
|
+
* The persistent `⎇ branch` badge in the session header.
|
|
13
13
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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:
|
|
41
|
-
* @param state - current
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
71
|
-
* @param props - slot runtime props, the
|
|
72
|
-
* @returns the
|
|
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
|
|
75
|
-
const { useSession,
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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 (
|
|
80
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
state.phase,
|
|
93
|
-
t
|
|
49
|
+
blank,
|
|
50
|
+
query,
|
|
51
|
+
workingDirectory
|
|
94
52
|
]);
|
|
95
|
-
(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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-
|
|
59
|
+
"data-branch": state.worktree.branch ?? "",
|
|
105
60
|
"data-hint": hint,
|
|
106
|
-
children:
|
|
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:
|
|
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.
|
|
67
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
113
68
|
type: "button",
|
|
114
|
-
|
|
115
|
-
"aria-
|
|
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
|
-
|
|
72
|
+
open();
|
|
122
73
|
},
|
|
123
|
-
children:
|
|
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
|
-
})
|
|
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/
|
|
80
|
+
// src/types.ts
|
|
143
81
|
/**
|
|
144
|
-
*
|
|
82
|
+
* Whether a leave that kept a copy left a 保留副本 behind.
|
|
145
83
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
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
|
-
*
|
|
270
|
-
* @param
|
|
271
|
-
* @returns the
|
|
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
|
|
274
|
-
|
|
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
|
-
*
|
|
355
|
-
* @param
|
|
356
|
-
* @
|
|
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.
|
|
454
|
+
this.run();
|
|
674
455
|
}
|
|
675
|
-
/**
|
|
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
|
-
...
|
|
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:
|
|
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.
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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-
|
|
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:
|
|
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
|
|
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
|
|
1109
|
-
|
|
1110
|
-
|
|
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: () => ({
|
|
1132
|
-
|
|
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;
|