@agou/dsh-worktrees 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,958 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-worktrees',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const { Button, Menu, Modal, IconFollowsystemOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives');
6
+ const h = React.createElement;
7
+ const NS = 'worktrees';
8
+ const PREFIX = 'dsh-worktrees';
9
+ const ACTION_SLOT = 'sidebar.workspaces.session.row.action';
10
+ const MARKER = `${PREFIX}-marker`;
11
+ const ROW = `[data-row-key^="session:"]:has(.${MARKER})`;
12
+ const FLAT_ROW = `[class*="_flatList"] ${ROW}`;
13
+ const TRANSITIONAL = new Set(['creating', 'snapshotting', 'removing', 'restoring']);
14
+ const CLEANED = new Set(['removed', 'missing']);
15
+ const EMPTY_ARRAY = Object.freeze([]);
16
+
17
+ // Trigger geometry follows AgentPresetSeat and HeroShell in DSH 0.2.1-rc.5.
18
+ // Native Menu and Modal own keyboard navigation, focus return and dismissal.
19
+ const CSS = `
20
+ [class$="_heroOptions"]:has(> [data-slot="conversation.hero.options"] .${PREFIX}-seat) { margin-left: 0; }
21
+ .${PREFIX}-anchor { min-width: 54px; max-width: 100%; }
22
+ .${PREFIX}-seat {
23
+ border-radius: var(--dsw-radius-sm); min-width: 0; max-width: min(100%,240px);
24
+ min-height: 28px; color: var(--dsw-alias-label-primary); white-space: nowrap;
25
+ cursor: pointer; background: transparent; border: none; align-items: center;
26
+ gap: 4px; padding: 0 8px; font-size: 13px; font-weight: 500; line-height: 20px;
27
+ display: inline-flex;
28
+ }
29
+ .${PREFIX}-seat:not(:disabled):hover, .${PREFIX}-seat[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); }
30
+ .${PREFIX}-seat:disabled { cursor: default; color: var(--dsw-alias-label-quaternary); }
31
+ .${PREFIX}-seat:focus-visible, .${PREFIX}-input:focus-visible {
32
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary)); outline-offset: -2px;
33
+ }
34
+ .${PREFIX}-seatLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
35
+ .${PREFIX}-seatIcon { flex: none; }
36
+ .${PREFIX}-chevron { color: var(--dsw-alias-label-caption); flex: none; }
37
+ .${PREFIX}-section { min-width: 0; color: var(--dsw-alias-label-primary); }
38
+ .${PREFIX}-setting { border-bottom: .5px solid var(--dsw-alias-border-l2); display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 16px 0; }
39
+ .${PREFIX}-settingLabel { min-width: 0; flex: 1; }
40
+ .${PREFIX}-title { font-size: 14px; line-height: 20px; }
41
+ .${PREFIX}-help { color: var(--dsw-alias-label-secondary); margin-top: 4px; font-size: 12px; line-height: 18px; }
42
+ .${PREFIX}-field { display: flex; align-items: center; gap: 8px; min-width: 0; width: min(52%,360px); }
43
+ .${PREFIX}-field > button { flex-shrink: 0; white-space: nowrap; }
44
+ .${PREFIX}-input { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-primary); min-width: 0; width: 100%; padding: 6px 8px; font: inherit; font-size: 13px; line-height: 20px; }
45
+ .${PREFIX}-number { width: 76px; flex: none; }
46
+ .${PREFIX}-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin: 24px 0 8px; }
47
+ .${PREFIX}-repo { margin: 24px 0 0; font-size: 14px; line-height: 20px; font-weight: 500; overflow-wrap: anywhere; }
48
+ .${PREFIX}-record { border-bottom: .5px solid var(--dsw-alias-border-l2); padding: 16px 0; }
49
+ .${PREFIX}-recordHead { display: flex; align-items: baseline; gap: 8px; }
50
+ .${PREFIX}-recordTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 20px; }
51
+ .${PREFIX}-status { flex: none; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
52
+ .${PREFIX}-path { color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; font-size: 12px; line-height: 18px; margin-top: 4px; }
53
+ .${PREFIX}-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
54
+ .${PREFIX}-error { color: var(--dsw-alias-state-error-primary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 18px; margin-top: 8px; }
55
+ .${PREFIX}-rawError { margin: 0; max-height: 50vh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; }
56
+ .${PREFIX}-modalAction { min-width: 72px; }
57
+ .${PREFIX}-dialogText { white-space: pre-wrap; overflow-wrap: anywhere; }
58
+ /* Match the native hover path/time foreground on the dark card. */
59
+ .${PREFIX}-hover { color: #cfd3d6; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
60
+ .${PREFIX}-hoverLine + .${PREFIX}-hoverLine { margin-top: 4px; }
61
+ .${MARKER} { color: var(--dsw-alias-label-tertiary); flex: none; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 20px; pointer-events: none; }
62
+ .${PREFIX}-rowIcon { flex: none; }
63
+ .${PREFIX}-rowInfo { display: none; }
64
+ ${ROW} > [class*="_rowActions"] { display: inline-flex; }
65
+ ${ROW}:not(:hover):not([class*="_menuOpen"]) > [class*="_rowActions"] > :not([data-slot="${ACTION_SLOT}"]),
66
+ ${ROW}:not(:hover):not([class*="_menuOpen"]) > [class*="_rowActions"] > [data-slot="${ACTION_SLOT}"] > :not(.${MARKER}):not(.dsh-session-workspace-label) { display: none; }
67
+ ${FLAT_ROW} { position: relative; box-sizing: border-box; height: 48px; padding-top: 4px; padding-bottom: 24px; }
68
+ ${FLAT_ROW} .${MARKER} { position: absolute; inset-inline: 28px 8px; bottom: 4px; width: auto; height: 17px; justify-content: flex-start; gap: 4px; min-width: 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 17px; font-weight: 400; }
69
+ ${FLAT_ROW} .${PREFIX}-rowInfo { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
70
+ ${FLAT_ROW}:has(.dsh-session-workspace-label) { height: 65px; padding-bottom: 41px; }
71
+ ${FLAT_ROW} .dsh-session-workspace-label { bottom: 21px; }
72
+ /* The workspace-label plugin hides other action children at rest. Override only our marker. */
73
+ ${FLAT_ROW}:not(:hover):not([class*="_menuOpen"]) > [class*="_rowActions"] > [data-slot="${ACTION_SLOT}"] > .${MARKER}.${MARKER} { display: inline-flex; }
74
+ @media (max-width: 600px) {
75
+ .${PREFIX}-setting { align-items: stretch; flex-direction: column; gap: 12px; }
76
+ .${PREFIX}-field { width: 100%; }
77
+ }
78
+ `;
79
+
80
+ // BEGIN GENERATED COPY: edit locale/*.json, then run pnpm locales.
81
+ const COPY = {
82
+ "en": {
83
+ "nav": "Worktrees",
84
+ "worktree": "Worktree",
85
+ "branch": "Branch",
86
+ "local": "Local",
87
+ "new": "New local worktree",
88
+ "loading": "Loading…",
89
+ "detached": "Detached HEAD",
90
+ "noGit": "This workspace is not a Git repository.",
91
+ "noBranches": "No branches are available.",
92
+ "noCommit": "Create a commit in this repository before creating a worktree.",
93
+ "gitUnavailable": "Git is unavailable. Local sessions can still use this workspace.",
94
+ "locked": "This session will continue in its prepared directory.",
95
+ "snapshotHelp": "Git-ignored files are not included. Restored changes are recorded in a snapshot commit.",
96
+ "localHint": "Use the source directory. Choosing a branch checks it out immediately.",
97
+ "newHint": "Create a local worktree when the first message is sent.",
98
+ "baseHint": "Choose the base branch for the new worktree.",
99
+ "directory": "Worktree directory",
100
+ "directoryHelp": "Applies to newly created worktrees. Existing worktrees keep their paths.",
101
+ "chooseDirectory": "Choose folder",
102
+ "maxRetained": "Maximum retained worktrees",
103
+ "retentionHelp": "Eligible worktrees above this limit are snapshotted before cleanup. Pinned sessions and active work are protected, so the total can exceed the limit.",
104
+ "invalidMaximum": "Enter a whole number of at least 1.",
105
+ "directoryRequired": "Enter a worktree directory.",
106
+ "managed": "Managed worktrees",
107
+ "refresh": "Refresh",
108
+ "empty": "No managed worktrees.",
109
+ "untitled": "Untitled session",
110
+ "openSessions": "Open session",
111
+ "archived": "Archived",
112
+ "basedOn": "Based on {branch}",
113
+ "rowWorktree": "Worktree",
114
+ "hoverPath": "Worktree path: {path}",
115
+ "hoverBranch": "Worktree branch: {branch}",
116
+ "ready": "Ready",
117
+ "active": "Active",
118
+ "pinned": "Pinned",
119
+ "protected": "Protected",
120
+ "creating": "Creating",
121
+ "snapshotting": "Saving snapshot",
122
+ "removing": "Cleaning up",
123
+ "removed": "Cleaned up",
124
+ "missing": "Directory missing",
125
+ "restoring": "Restoring",
126
+ "error": "Error",
127
+ "restore": "Restore",
128
+ "delete": "Delete",
129
+ "cancel": "Cancel",
130
+ "close": "Close",
131
+ "restoreTitle": "Restore worktree?",
132
+ "restoreBody": "This worktree has been cleaned up. Restore its saved snapshot to continue in a detached worktree.",
133
+ "deleteTitle": "Delete worktree?",
134
+ "deleteBody": "This removes the worktree directory and archives its associated chats. A saved snapshot remains available for restoration.",
135
+ "deleteProtected": "Active worktrees and worktrees with pinned sessions cannot be deleted.",
136
+ "actionError": "Worktree operation failed",
137
+ "checkoutError": "Could not switch branch",
138
+ "archivedHelp": "Unarchive this session in the sidebar to open it.",
139
+ "invalidResponse": "The worktree service returned an invalid response.",
140
+ "notReady": "The worktree is not ready. Refresh and try again.",
141
+ "adapterUnavailable": "This DSH version does not expose the supported conversation send method."
142
+ },
143
+ "zh": {
144
+ "nav": "工作树",
145
+ "worktree": "工作树",
146
+ "branch": "分支",
147
+ "local": "本地",
148
+ "new": "新建本地工作树",
149
+ "loading": "正在加载…",
150
+ "detached": "分离的 HEAD",
151
+ "noGit": "此工作区不是 Git 仓库。",
152
+ "noBranches": "没有可用的分支。",
153
+ "noCommit": "请先在仓库中创建提交,再新建工作树。",
154
+ "gitUnavailable": "Git 不可用,此工作区仍可用于本地会话。",
155
+ "locked": "此会话将在已经准备好的目录中继续。",
156
+ "snapshotHelp": "快照不包含被 Git 忽略的文件。恢复后的修改会记录为一次快照提交。",
157
+ "localHint": "使用源目录。选择分支会立即切换源目录的分支。",
158
+ "newHint": "发送第一条消息时创建本地工作树。",
159
+ "baseHint": "选择新工作树的基础分支。",
160
+ "directory": "工作树目录",
161
+ "directoryHelp": "仅影响新建的工作树,现有工作树的路径保持不变。",
162
+ "chooseDirectory": "选择文件夹",
163
+ "maxRetained": "最多保留工作树数量",
164
+ "retentionHelp": "超过此数量的可清理工作树会先保存快照,再清理目录。置顶会话和正在运行的工作树受到保护,因此总数可能超过此限制。",
165
+ "invalidMaximum": "请输入不小于 1 的整数。",
166
+ "directoryRequired": "请输入工作树目录。",
167
+ "managed": "管理工作树",
168
+ "refresh": "刷新",
169
+ "empty": "暂无托管工作树。",
170
+ "untitled": "未命名会话",
171
+ "openSessions": "打开会话",
172
+ "archived": "已归档",
173
+ "basedOn": "基于 {branch}",
174
+ "rowWorktree": "工作树",
175
+ "hoverPath": "工作树路径:{path}",
176
+ "hoverBranch": "工作树分支:{branch}",
177
+ "ready": "就绪",
178
+ "active": "运行中",
179
+ "pinned": "已置顶",
180
+ "protected": "受到保护",
181
+ "creating": "正在创建",
182
+ "snapshotting": "正在保存快照",
183
+ "removing": "正在清理",
184
+ "removed": "已清理",
185
+ "missing": "目录缺失",
186
+ "restoring": "正在恢复",
187
+ "error": "错误",
188
+ "restore": "恢复",
189
+ "delete": "删除",
190
+ "cancel": "取消",
191
+ "close": "关闭",
192
+ "restoreTitle": "恢复工作树?",
193
+ "restoreBody": "此工作树已经清理。恢复保存的快照后,将在分离的工作树中继续工作。",
194
+ "deleteTitle": "删除工作树?",
195
+ "deleteBody": "这会删除工作树目录,并归档关联会话。保存的快照仍可用于恢复。",
196
+ "deleteProtected": "正在运行的工作树和包含置顶会话的工作树不能删除。",
197
+ "actionError": "工作树操作失败",
198
+ "checkoutError": "无法切换分支",
199
+ "archivedHelp": "请先在侧边栏取消归档,再打开此会话。",
200
+ "invalidResponse": "工作树服务返回了无效结果。",
201
+ "notReady": "工作树尚未就绪,请刷新后重试。",
202
+ "adapterUnavailable": "此 DSH 版本没有提供受支持的会话发送方法。"
203
+ }
204
+ };
205
+ // END GENERATED COPY
206
+
207
+ function createStore(initial) {
208
+ let snapshot = initial;
209
+ const listeners = new Set();
210
+ return {
211
+ getSnapshot: () => snapshot,
212
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
213
+ set(next) {
214
+ if (Object.is(next, snapshot)) return;
215
+ snapshot = next;
216
+ for (const listener of listeners) listener();
217
+ },
218
+ };
219
+ }
220
+ function useStore(store) {
221
+ return React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
222
+ }
223
+ function useSelected(store, selector) {
224
+ const get = React.useCallback(() => selector(store.getSnapshot()), [store, selector]);
225
+ return React.useSyncExternalStore(store.subscribe, get, get);
226
+ }
227
+ function branchLabel(ref) {
228
+ return typeof ref === 'string' ? ref.replace(/^refs\/(heads|remotes)\//, '') : '';
229
+ }
230
+ function baseFor(info, preference) {
231
+ const ref = preference?.baseRef;
232
+ return ref && (ref === 'HEAD' || info?.branches.some((branch) => branch.ref === ref)) ? ref : info?.defaultBase;
233
+ }
234
+ function basename(path) {
235
+ return path?.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path || '';
236
+ }
237
+ function messageOf(error) { return error instanceof Error ? error.message : String(error); }
238
+ function aborted(error) { return error instanceof Error && error.name === 'AbortError'; }
239
+ function workspaceOf(items, sessionId) {
240
+ return items.find((workspace) => workspace.sessionIds.includes(sessionId));
241
+ }
242
+
243
+ function createController(ctx) {
244
+ const t = ctx.locale.bind(NS);
245
+ const rpc = ctx.get('connection').rpc;
246
+ const lifetime = new AbortController();
247
+ const requests = new Set();
248
+ const workspaces = new Map();
249
+ const choices = new Map();
250
+ const pendingOperations = new Set();
251
+ const prepared = new Set();
252
+ const reportedErrors = new WeakSet();
253
+ const dialogs = [];
254
+ const dialog = createStore(null);
255
+ const data = createStore({
256
+ phase: 'idle', error: null, records: EMPTY_ARRAY, bindings: EMPTY_ARRAY,
257
+ preferences: EMPTY_ARRAY, config: null, revision: undefined,
258
+ recordById: new Map(), bindingBySession: new Map(), observedBySession: new Map(), preferenceByWorkspace: new Map(),
259
+ directoryPickerKind: null,
260
+ });
261
+ const drafts = createStore(0);
262
+ let listPending;
263
+ let refreshAgain = false;
264
+ let listEpoch = 0;
265
+
266
+ async function call(endpoint, payload, signal) {
267
+ const request = new AbortController();
268
+ const abort = () => request.abort();
269
+ if (lifetime.signal.aborted || signal?.aborted) request.abort();
270
+ lifetime.signal.addEventListener('abort', abort, { once: true });
271
+ signal?.addEventListener('abort', abort, { once: true });
272
+ requests.add(request);
273
+ try {
274
+ const result = await ctx.get('connection').rpc.call('/api', `dsh-worktrees/${endpoint}`, payload, request.signal);
275
+ if (request.signal.aborted) throw new DOMException('Aborted', 'AbortError');
276
+ if (!result || typeof result.ok !== 'boolean') throw new Error(t('invalidResponse'));
277
+ if (!result.ok) {
278
+ const error = new Error(result.error?.message || t('invalidResponse'));
279
+ error.code = result.error?.code;
280
+ error.details = result.error?.details;
281
+ throw error;
282
+ }
283
+ return result.value;
284
+ } finally {
285
+ requests.delete(request);
286
+ lifetime.signal.removeEventListener('abort', abort);
287
+ signal?.removeEventListener('abort', abort);
288
+ }
289
+ }
290
+
291
+ function refresh(force = false) {
292
+ if (lifetime.signal.aborted) return Promise.resolve(false);
293
+ if (listPending) {
294
+ if (force) { refreshAgain = true; listEpoch += 1; }
295
+ return listPending;
296
+ }
297
+ const epoch = ++listEpoch;
298
+ data.set({ ...data.getSnapshot(), phase: 'loading', error: null });
299
+ listPending = call('list', { refresh: force }).then((value) => {
300
+ if (!value || !Array.isArray(value.records) || !Array.isArray(value.bindings)
301
+ || (value.observed !== undefined && !Array.isArray(value.observed))
302
+ || !Array.isArray(value.preferences) || typeof value.config?.directory !== 'string'
303
+ || !Number.isInteger(value.config.maxRetained) || value.config.maxRetained < 1
304
+ || !Number.isSafeInteger(value.revision)) throw new Error(t('invalidResponse'));
305
+ if (lifetime.signal.aborted || epoch !== listEpoch) return false;
306
+ data.set({
307
+ ...value, phase: 'ready', error: null,
308
+ recordById: new Map(value.records.map((record) => [record.id, record])),
309
+ bindingBySession: new Map(value.bindings.map((binding) => [binding.sessionId, binding])),
310
+ observedBySession: new Map((value.observed ?? EMPTY_ARRAY).flatMap(record => record.sessionIds.map(id => [id, record]))),
311
+ preferenceByWorkspace: new Map(value.preferences.map((preference) => [preference.workspaceId, preference])),
312
+ });
313
+ return true;
314
+ }).catch((error) => {
315
+ if (!lifetime.signal.aborted && epoch === listEpoch && !aborted(error)) {
316
+ data.set({ ...data.getSnapshot(), phase: 'error', error: messageOf(error) });
317
+ }
318
+ return false;
319
+ }).finally(() => {
320
+ listPending = undefined;
321
+ if (refreshAgain && !lifetime.signal.aborted) { refreshAgain = false; return refresh(true); }
322
+ });
323
+ return listPending;
324
+ }
325
+
326
+ function workspaceState(workspaceId) {
327
+ let entry = workspaces.get(workspaceId);
328
+ if (!entry) {
329
+ entry = {
330
+ store: createStore({ phase: 'idle', info: null, error: null, busy: false }),
331
+ request: null, pending: null, checkout: null, seq: 0,
332
+ };
333
+ workspaces.set(workspaceId, entry);
334
+ }
335
+ return entry;
336
+ }
337
+ function loadWorkspace(workspaceId, force = false) {
338
+ const entry = workspaceState(workspaceId);
339
+ if (!force && entry.pending) return entry.pending;
340
+ if (!force && entry.store.getSnapshot().phase === 'ready') return Promise.resolve();
341
+ entry.request?.abort();
342
+ const request = new AbortController();
343
+ const seq = ++entry.seq;
344
+ entry.request = request;
345
+ entry.store.set({ ...entry.store.getSnapshot(), phase: 'loading', error: null });
346
+ entry.pending = call('workspace', { workspaceId }, request.signal).then((info) => {
347
+ if (!info || info.workspaceId !== workspaceId || typeof info.isGit !== 'boolean'
348
+ || !Array.isArray(info.branches)) throw new Error(t('invalidResponse'));
349
+ if (seq === entry.seq) entry.store.set({ ...entry.store.getSnapshot(), phase: 'ready', info, error: null });
350
+ }).catch((error) => {
351
+ if (!lifetime.signal.aborted && seq === entry.seq && !aborted(error)) {
352
+ entry.store.set({ ...entry.store.getSnapshot(), phase: 'error', error: messageOf(error) });
353
+ }
354
+ }).finally(() => { if (seq === entry.seq) entry.pending = null; });
355
+ return entry.pending;
356
+ }
357
+ function choose(sessionId, choice) {
358
+ choices.set(sessionId, choice);
359
+ drafts.set(drafts.getSnapshot() + 1);
360
+ }
361
+ function choiceFor(sessionId, workspaceId, info) {
362
+ const state = data.getSnapshot();
363
+ const binding = state.bindingBySession.get(sessionId);
364
+ if (binding) return binding;
365
+ const draft = choices.get(sessionId);
366
+ if (draft) return draft;
367
+ const saved = state.preferenceByWorkspace.get(workspaceId) ?? info?.choice;
368
+ if (info?.isGit && saved?.mode === 'new') return { mode: 'new', baseRef: baseFor(info, saved) };
369
+ return { mode: 'local' };
370
+ }
371
+ function settleDialog(item, accepted) {
372
+ const index = dialogs.indexOf(item);
373
+ if (index < 0) return;
374
+ dialogs.splice(index, 1);
375
+ item.cleanup?.();
376
+ item.resolve?.(accepted);
377
+ dialog.set(dialogs[0] ?? null);
378
+ }
379
+ function confirm(kind, path, signal) {
380
+ if (lifetime.signal.aborted || signal?.aborted) return Promise.resolve(false);
381
+ return new Promise((resolve) => {
382
+ const item = { kind, path, resolve };
383
+ const cancel = () => settleDialog(item, false);
384
+ item.cleanup = () => signal?.removeEventListener('abort', cancel);
385
+ signal?.addEventListener('abort', cancel, { once: true });
386
+ dialogs.push(item);
387
+ dialog.set(dialogs[0]);
388
+ });
389
+ }
390
+ function report(error, title = 'actionError') {
391
+ if (lifetime.signal.aborted || aborted(error)) return;
392
+ if (error instanceof Error) {
393
+ if (reportedErrors.has(error)) return;
394
+ reportedErrors.add(error);
395
+ }
396
+ dialogs.push({ kind: 'error', title, message: messageOf(error) });
397
+ dialog.set(dialogs[0]);
398
+ }
399
+ function checkout(workspaceId, branch) {
400
+ const entry = workspaceState(workspaceId);
401
+ if (entry.checkout) return entry.checkout;
402
+ entry.store.set({ ...entry.store.getSnapshot(), busy: true });
403
+ entry.checkout = call('checkout', { workspaceId, branch }).then(() => loadWorkspace(workspaceId, true))
404
+ .finally(() => {
405
+ entry.checkout = null;
406
+ entry.store.set({ ...entry.store.getSnapshot(), busy: false });
407
+ });
408
+ return entry.checkout;
409
+ }
410
+ async function prepare(session, operationId, signal) {
411
+ const sessionId = session.sessionId;
412
+ const workspace = workspaceOf(ctx.get('workspaces').list.getSnapshot().items, sessionId);
413
+ if (!workspace && data.getSnapshot().phase !== 'ready') {
414
+ await refresh();
415
+ if (data.getSnapshot().phase !== 'ready') throw new Error(data.getSnapshot().error || t('notReady'));
416
+ }
417
+ if (!workspace && !data.getSnapshot().bindingBySession.has(sessionId) && !prepared.has(sessionId)) return;
418
+ if (workspace) await workspaceState(workspace.workspaceId).checkout;
419
+ if (signal?.aborted || lifetime.signal.aborted) throw new DOMException('Aborted', 'AbortError');
420
+ const binding = data.getSnapshot().bindingBySession.get(sessionId);
421
+ const choice = binding || prepared.has(sessionId) ? undefined : choices.get(sessionId);
422
+ const payload = { sessionId, operationId, ...(choice ? { choice } : {}) };
423
+ pendingOperations.add(operationId);
424
+ let result = await call('prepare', payload, signal);
425
+ if (result?.requiresRestore === true) {
426
+ const record = data.getSnapshot().recordById.get(result.worktreeId);
427
+ if (!await confirm('restore', result.path ?? record?.path, signal)) return false;
428
+ await call('restore', { worktreeId: result.worktreeId }, signal);
429
+ result = await call('prepare', payload, signal);
430
+ }
431
+ if (result?.ready !== true || typeof result.actualDirectory !== 'string' || result.actualDirectory === '') {
432
+ throw new Error(t('notReady'));
433
+ }
434
+ prepared.add(sessionId);
435
+ void refresh(true);
436
+ return true;
437
+ }
438
+ async function settle(sessionId, operationId) {
439
+ if (!pendingOperations.delete(operationId)) return;
440
+ try {
441
+ // Native admission can outlive Client plugin disposal. Its reservation is
442
+ // released afterwards through the captured connection, not an aborted UI request.
443
+ const result = await rpc.call('/api', 'dsh-worktrees/settle', { sessionId, operationId });
444
+ if (!result?.ok) throw new Error(result?.error?.message || t('invalidResponse'));
445
+ }
446
+ catch (error) {
447
+ // A cleanup failure must never turn an admitted message into a failed send.
448
+ if (!aborted(error)) console.warn(`[${PREFIX}] Could not release send preparation:`, error);
449
+ void refresh(true);
450
+ }
451
+ }
452
+ async function recordAction(action, record) {
453
+ if (!await confirm(action, record.path)) return;
454
+ try { await call(action === 'delete' ? 'remove' : 'restore', { worktreeId: record.id }); }
455
+ catch (error) { report(error); }
456
+ finally { await refresh(true); }
457
+ }
458
+ function reset() {
459
+ for (const entry of workspaces.values()) {
460
+ entry.seq += 1;
461
+ entry.request?.abort();
462
+ entry.pending = null;
463
+ entry.store.set({ ...entry.store.getSnapshot(), phase: 'idle' });
464
+ }
465
+ listEpoch += 1;
466
+ void refresh(true);
467
+ }
468
+ return {
469
+ data, drafts, dialog, lifetime, refresh, call, workspaceState, loadWorkspace,
470
+ choiceFor, choose, checkout, prepare, settle, report, confirm, settleDialog, recordAction, reset,
471
+ accepted(sessionId) {
472
+ choices.delete(sessionId);
473
+ drafts.set(drafts.getSnapshot() + 1);
474
+ void refresh(true);
475
+ },
476
+ dispose() {
477
+ lifetime.abort();
478
+ for (const request of requests) request.abort();
479
+ for (const entry of workspaces.values()) entry.request?.abort();
480
+ for (const item of [...dialogs]) settleDialog(item, false);
481
+ choices.clear();
482
+ prepared.clear();
483
+ },
484
+ };
485
+ }
486
+
487
+ function installSendGate(ctx, controller) {
488
+ const service = ctx.get('conversation');
489
+ const own = Object.getOwnPropertyDescriptor(service, 'sendSession');
490
+ let owner = service;
491
+ let descriptor;
492
+ while (owner && !descriptor) {
493
+ descriptor = Object.getOwnPropertyDescriptor(owner, 'sendSession');
494
+ owner = Object.getPrototypeOf(owner);
495
+ }
496
+ const original = descriptor?.value;
497
+ if (typeof original !== 'function') {
498
+ controller.report(new Error(ctx.locale.bind(NS)('adapterUnavailable')));
499
+ return () => {};
500
+ }
501
+ let active = true;
502
+ // Cordis traces service method reads. Capture the raw descriptor method and
503
+ // define an instance property so callers keep their traced `this` context.
504
+ async function sendSession(session, text, attachmentIds, mode, signal) {
505
+ if (!active) return original.call(this, session, text, attachmentIds, mode, signal);
506
+ const operationId = globalThis.crypto.randomUUID();
507
+ try {
508
+ try {
509
+ if (await controller.prepare(session, operationId, signal) === false) return { kind: 'error' };
510
+ if (!active || signal?.aborted) return { kind: 'error' };
511
+ } catch (error) {
512
+ controller.report(error);
513
+ // No text: native input restores the draft and attachments without a notice.
514
+ return { kind: 'error' };
515
+ }
516
+ const result = await original.call(this, session, text, attachmentIds, mode, signal);
517
+ if (active && result.kind === 'success') controller.accepted(session.sessionId);
518
+ return result;
519
+ } finally { await controller.settle(session.sessionId, operationId); }
520
+ }
521
+ Object.defineProperty(service, 'sendSession', { configurable: true, writable: true, value: sendSession });
522
+ return () => {
523
+ active = false;
524
+ // Later wrappers may still call ours: leave their property and bypass our gate.
525
+ if (Object.getOwnPropertyDescriptor(service, 'sendSession')?.value !== sendSession) return;
526
+ if (own) Object.defineProperty(service, 'sendSession', own);
527
+ else delete service.sendSession;
528
+ };
529
+ }
530
+
531
+ // DSH 0.2.1-rc.5's native header reads the immutable session header cwd.
532
+ // Delegate the native cell with a selector-local view of the live directory.
533
+ function installOpenInAdapter(ctx) {
534
+ const name = 'conversation.session.header.utilities';
535
+ return ctx.slots.inject(name, () => {
536
+ let active = true;
537
+ let original;
538
+ let component;
539
+ let remove;
540
+ let warned = false;
541
+ const clear = () => {
542
+ remove?.();
543
+ remove = undefined;
544
+ original = undefined;
545
+ component = undefined;
546
+ };
547
+ const sync = () => {
548
+ if (!active) return;
549
+ const entries = ctx.slots.entries(name).filter((entry) =>
550
+ entry.options.id === 'open-in-app' && entry.component !== component);
551
+ const native = entries.length === 1 && entries[0].locale === 'open-in-app'
552
+ && (entries[0].options.priority ?? 0) === 0 ? entries[0] : undefined;
553
+ if (!native) {
554
+ clear();
555
+ if (entries.length > 0 && !warned) {
556
+ console.warn(`[${PREFIX}] The native Open-in-app cell is overridden; leaving that action to its owner.`);
557
+ warned = true;
558
+ }
559
+ return;
560
+ }
561
+ warned = false;
562
+ if (original === native) return;
563
+ clear();
564
+ original = native;
565
+ component = function SessionDirectoryOpenIn(props) {
566
+ const { sessionId, useSessions, useProjection } = props;
567
+ const directory = useProjection('workingDirectory');
568
+ // Unknown projections must never briefly open the source checkout.
569
+ const cwd = typeof directory === 'string' && directory.length > 0 ? directory : undefined;
570
+ const useDirectorySessions = React.useCallback((select, equals) => useSessions((state) => {
571
+ const row = state.byId[sessionId];
572
+ if (!row || row.cwd === cwd) return select(state);
573
+ return select({ ...state, byId: { ...state.byId, [sessionId]: { ...row, cwd } } });
574
+ }, equals), [sessionId, useSessions, cwd]);
575
+ return h(native.component, { ...props, useSessions: useDirectorySessions });
576
+ };
577
+ remove = ctx.slots.register({
578
+ // SlotCore elects the lowest priority; the native cell is at zero.
579
+ name, id: 'open-in-app', order: native.options.order, priority: -1,
580
+ locale: native.locale, inject: native.inject,
581
+ }, component);
582
+ };
583
+ const unsubscribe = ctx.slots.subscribe(name, sync);
584
+ sync();
585
+ return () => { active = false; unsubscribe(); clear(); };
586
+ });
587
+ }
588
+
589
+ function NativeSelector({ icon: Icon = IconBranchOutlineRegular, label, title, disabled, items, selectedId, onSelect, open, setOpen, ariaLabel }) {
590
+ return h(Menu, {
591
+ open: open && !disabled, onClose: () => setOpen(false), items, selectedId,
592
+ onSelect: (id) => { setOpen(false); onSelect(id); }, align: 'start', portal: true,
593
+ className: `${PREFIX}-anchor`,
594
+ anchor: h('button', {
595
+ type: 'button', className: `${PREFIX}-seat`, disabled, title: title ? `${label}\n${title}` : label,
596
+ 'aria-label': `${ariaLabel}: ${label}`, 'aria-haspopup': 'menu', 'aria-expanded': open && !disabled,
597
+ onClick: () => setOpen(!open),
598
+ onKeyDown: (event) => {
599
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); setOpen(true); }
600
+ },
601
+ }, h(Icon, { size: 16, className: `${PREFIX}-seatIcon` }),
602
+ h('span', { className: `${PREFIX}-seatLabel` }, label),
603
+ h(IconChevronDownOutlineRegular, { size: 12, className: `${PREFIX}-chevron` })),
604
+ });
605
+ }
606
+
607
+ function WorktreeOptions(props) {
608
+ const { sessionId, useWorkspaces } = props;
609
+ const workspaceId = useWorkspaces((state) => workspaceOf(state.items, sessionId)?.workspaceId);
610
+ if (!workspaceId) return null;
611
+ return h(WorkspaceOptions, { ...props, workspaceId, key: `${sessionId}:${workspaceId}` });
612
+ }
613
+
614
+ function WorkspaceOptions({ sessionId, workspaceId, useSessionRetainInfo, controller, t }) {
615
+ const main = useSessionRetainInfo((info) => (info?.retainedBy.mainView ?? 0) > 0);
616
+ const entry = controller.workspaceState(workspaceId);
617
+ const state = useStore(entry.store);
618
+ useStore(controller.drafts);
619
+ const binding = useSelected(controller.data, React.useCallback((snapshot) => snapshot.bindingBySession.get(sessionId), [sessionId]));
620
+ useSelected(controller.data, React.useCallback((snapshot) => snapshot.preferenceByWorkspace.get(workspaceId), [workspaceId]));
621
+ const [worktreeOpen, setWorktreeOpen] = React.useState(false);
622
+ const [branchOpen, setBranchOpen] = React.useState(false);
623
+ React.useEffect(() => {
624
+ if (!main) return;
625
+ void controller.loadWorkspace(workspaceId, true);
626
+ return () => {
627
+ entry.seq += 1;
628
+ entry.request?.abort();
629
+ entry.pending = null;
630
+ entry.store.set({ ...entry.store.getSnapshot(), phase: 'idle' });
631
+ };
632
+ }, [controller, workspaceId, entry, main]);
633
+ React.useEffect(() => {
634
+ if (main && state.phase === 'idle') void controller.loadWorkspace(workspaceId);
635
+ }, [controller, workspaceId, state.phase, main]);
636
+ if (!main) return null;
637
+ const info = state.info;
638
+ const choice = controller.choiceFor(sessionId, workspaceId, info);
639
+ const loading = state.phase === 'idle' || state.phase === 'loading';
640
+ const unavailable = state.error || (info?.reason === 'git-unavailable' ? t('gitUnavailable')
641
+ : !info?.isGit ? t('noGit') : info.reason === 'unborn-head' ? t('noCommit') : null);
642
+ const branches = (info?.branches ?? EMPTY_ARRAY).filter((branch) => choice.mode === 'new' || !branch.remote);
643
+ const base = choice.baseRef || info?.defaultBase;
644
+ const branch = choice.mode === 'new' ? branchLabel(base)
645
+ : info?.currentBranch || (info?.isGit ? `${t('detached')} ${info.head?.slice(0, 7) ?? ''}`.trim() : t('branch'));
646
+ const selectedBranch = choice.mode === 'new' ? base
647
+ : branches.find((item) => item.name === info?.currentBranch)?.ref;
648
+ const setMode = (mode) => {
649
+ setBranchOpen(false);
650
+ if (mode === 'local') controller.choose(sessionId, { mode: 'local' });
651
+ else {
652
+ const saved = controller.data.getSnapshot().preferenceByWorkspace.get(workspaceId) ?? info?.choice;
653
+ controller.choose(sessionId, { mode: 'new', baseRef: baseFor(info, saved) });
654
+ }
655
+ };
656
+ return h(React.Fragment, null,
657
+ h(NativeSelector, {
658
+ icon: choice.mode === 'new' ? IconBranchOutlineRegular : IconFollowsystemOutlineRegular,
659
+ label: choice.mode === 'new' ? t('new') : t('local'), ariaLabel: t('worktree'),
660
+ title: binding ? t('locked') : unavailable || t(choice.mode === 'new' ? 'newHint' : 'localHint'),
661
+ disabled: loading || state.busy || !!unavailable || !!binding, open: worktreeOpen,
662
+ setOpen: (value) => { setBranchOpen(false); setWorktreeOpen(value); },
663
+ items: [
664
+ { id: 'local', label: t('local'), icon: h(IconFollowsystemOutlineRegular, { size: 16 }) },
665
+ { id: 'new', label: h('span', { title: unavailable || t('newHint') }, t('new')), icon: h(IconBranchOutlineRegular, { size: 16 }), disabled: !!unavailable },
666
+ ], selectedId: choice.mode, onSelect: setMode,
667
+ }),
668
+ h(NativeSelector, {
669
+ label: loading ? t('loading') : branch || t('branch'), ariaLabel: t('branch'),
670
+ title: binding?.mode === 'new' ? t('locked') : unavailable || (branches.length === 0 ? t('noBranches') : t(choice.mode === 'new' ? 'baseHint' : 'localHint')),
671
+ disabled: loading || state.busy || !!unavailable || branches.length === 0 || binding?.mode === 'new',
672
+ open: branchOpen, setOpen: (value) => { setWorktreeOpen(false); setBranchOpen(value); },
673
+ items: branches.map((item) => ({ id: item.ref, label: item.name, icon: h(IconBranchOutlineRegular, { size: 16 }) })), selectedId: selectedBranch,
674
+ onSelect: (ref) => {
675
+ if (choice.mode === 'new') controller.choose(sessionId, { mode: 'new', baseRef: ref });
676
+ else {
677
+ const chosen = branches.find((item) => item.ref === ref);
678
+ if (chosen && chosen.name !== info.currentBranch) {
679
+ void controller.checkout(workspaceId, chosen.name).catch((error) => controller.report(error, 'checkoutError'));
680
+ }
681
+ }
682
+ },
683
+ }));
684
+ }
685
+
686
+ function GlobalOverlay({ controller, useSessions, useSessionStatus, t }) {
687
+ const current = useStore(controller.dialog);
688
+ const mainSessionId = useSessions((state) => Object.values(state.byId).find((session) => session.retainedBy.mainView > 0)?.id);
689
+ const statuses = useSessionStatus((state) => state);
690
+ const bindings = useSelected(controller.data, React.useCallback((state) => state.bindingBySession, []));
691
+ const previousRunning = React.useRef(new Set());
692
+ React.useEffect(() => { void controller.refresh(true); }, [controller, mainSessionId]);
693
+ React.useEffect(() => {
694
+ const update = () => { if (document.visibilityState === 'visible') void controller.refresh(); };
695
+ const visible = () => { if (document.visibilityState === 'visible') void controller.refresh(true); };
696
+ const timer = window.setInterval(update, 5000);
697
+ document.addEventListener('visibilitychange', visible);
698
+ return () => { window.clearInterval(timer); document.removeEventListener('visibilitychange', visible); };
699
+ }, [controller]);
700
+ React.useEffect(() => {
701
+ const next = new Set();
702
+ for (const [id, status] of statuses) if (bindings.has(id) && status.running) next.add(id);
703
+ if ([...previousRunning.current].some((id) => !next.has(id))) void controller.refresh(true);
704
+ previousRunning.current = next;
705
+ }, [controller, statuses, bindings]);
706
+ const close = () => { if (current) controller.settleDialog(current, false); };
707
+ return h(React.Fragment, null,
708
+ h('style', { 'data-plugin': PREFIX, 'data-plugin-css': `${PREFIX}/client.css` }, CSS),
709
+ h(Modal, {
710
+ open: current !== null, onClose: close, closeLabel: t('close'),
711
+ title: current?.kind === 'error' ? t(current.title) : t(current?.kind === 'delete' ? 'deleteTitle' : 'restoreTitle'),
712
+ footer: current?.kind === 'error'
713
+ ? h(Button, { variant: 'primary', className: `${PREFIX}-modalAction`, onClick: close }, t('close'))
714
+ : h(React.Fragment, null,
715
+ h(Button, { variant: 'outline', className: `${PREFIX}-modalAction`, onClick: close }, t('cancel')),
716
+ h(Button, { variant: 'primary', className: `${PREFIX}-modalAction`, onClick: () => controller.settleDialog(current, true) }, t(current?.kind === 'delete' ? 'delete' : 'restore'))),
717
+ }, current?.kind === 'error'
718
+ ? h('pre', { className: `${PREFIX}-rawError`, role: 'alert' }, current.message)
719
+ : h('div', { className: `${PREFIX}-dialogText` },
720
+ t(current?.kind === 'delete' ? 'deleteBody' : 'restoreBody'),
721
+ h('div', { className: `${PREFIX}-help` }, t('snapshotHelp')),
722
+ current?.path ? h('div', { className: `${PREFIX}-path` }, current.path) : null)));
723
+ }
724
+
725
+ function useSessionWorktree(controller, sessionId) {
726
+ return useSelected(controller.data, React.useCallback(state => {
727
+ const binding = state.bindingBySession.get(sessionId);
728
+ return binding?.mode === 'new' ? state.recordById.get(binding.worktreeId) : state.observedBySession.get(sessionId);
729
+ }, [sessionId]));
730
+ }
731
+
732
+ function WorktreeMarker({ sessionId, controller, t }) {
733
+ const record = useSessionWorktree(controller, sessionId);
734
+ if (!record) return null;
735
+ const base = branchLabel(record.baseRef);
736
+ const detail = CLEANED.has(record.status) ? t(record.status)
737
+ : record.external ? record.currentBranch || t('detached')
738
+ : base ? t('basedOn', { branch: base }) : t('rowWorktree');
739
+ const label = detail === t('rowWorktree') ? detail : `${t('rowWorktree')} · ${detail}`;
740
+ return h('span', { className: MARKER, role: 'img', 'aria-label': label, title: label },
741
+ h(IconBranchOutlineRegular, { size: 14, className: `${PREFIX}-rowIcon` }),
742
+ h('span', { className: `${PREFIX}-rowInfo`, 'aria-hidden': true }, detail));
743
+ }
744
+
745
+ function WorktreeHover({ sessionId, controller, t }) {
746
+ const record = useSessionWorktree(controller, sessionId);
747
+ if (!record) return null;
748
+ const lines = [
749
+ CLEANED.has(record.status) ? t(record.status) : null,
750
+ t('hoverPath', { path: record.path }),
751
+ record.status === 'ready' && Object.hasOwn(record, 'currentBranch')
752
+ ? t('hoverBranch', { branch: record.currentBranch || t('detached') }) : null,
753
+ ];
754
+ return h('div', { className: `${PREFIX}-hover` }, lines.filter(Boolean).map((line, index) =>
755
+ h('div', { className: `${PREFIX}-hoverLine`, key: index }, line)));
756
+ }
757
+
758
+ function statusLabel(record, statuses, pinned, t) {
759
+ const labels = [];
760
+ if (record.sessionIds.some((id) => statuses.get(id)?.running)) labels.push(t('active'));
761
+ if (record.sessionIds.some((id) => pinned.has(id))) labels.push(t('pinned'));
762
+ if (record.protected && labels.length === 0 && record.status === 'ready') labels.push(t('protected'));
763
+ if (record.status !== 'ready' || labels.length === 0) labels.push(t(record.status));
764
+ return labels.join(' · ');
765
+ }
766
+
767
+ function RecordRow({ record, sessions, statuses, pinned, archived, controller, openSession, close, t }) {
768
+ const [menuOpen, setMenuOpen] = React.useState(false);
769
+ const [busy, setBusy] = React.useState(false);
770
+ const protectedRecord = record.protected || record.sessionIds.some((id) => pinned.has(id) || statuses.get(id)?.running);
771
+ const transitioning = TRANSITIONAL.has(record.status);
772
+ const title = record.sessionIds.map((id) => sessions[id]?.title || sessions[id]?.displayTitle).find(Boolean) || t('untitled');
773
+ const act = async (action) => {
774
+ setBusy(true);
775
+ try { await controller.recordAction(action, record); }
776
+ finally { setBusy(false); }
777
+ };
778
+ return h('article', { className: `${PREFIX}-record` },
779
+ h('div', { className: `${PREFIX}-recordHead` },
780
+ h('div', { className: `${PREFIX}-recordTitle`, title }, title),
781
+ h('span', { className: `${PREFIX}-status` }, statusLabel(record, statuses, pinned, t))),
782
+ h('div', { className: `${PREFIX}-path` }, record.path),
783
+ record.baseRef ? h('div', { className: `${PREFIX}-help` }, t('basedOn', { branch: branchLabel(record.baseRef) })) : null,
784
+ record.error ? h('div', { className: `${PREFIX}-error` }, record.error) : null,
785
+ h('div', { className: `${PREFIX}-actions` },
786
+ h(Menu, {
787
+ open: menuOpen, onClose: () => setMenuOpen(false), portal: true, align: 'start',
788
+ items: record.sessionIds.map((id) => ({
789
+ id, disabled: archived.has(id),
790
+ label: h('span', { title: archived.has(id) ? t('archivedHelp') : sessions[id]?.displayTitle || id },
791
+ sessions[id]?.title || sessions[id]?.displayTitle || t('untitled'), archived.has(id) ? ` · ${t('archived')}` : ''),
792
+ })),
793
+ onSelect: (id) => { setMenuOpen(false); close(); openSession(id); },
794
+ anchor: h(Button, {
795
+ variant: 'outline', size: 'sm', disabled: record.sessionIds.length === 0,
796
+ 'aria-haspopup': 'menu', 'aria-expanded': menuOpen, onClick: () => setMenuOpen(!menuOpen),
797
+ }, t('openSessions')),
798
+ }),
799
+ record.snapshotRef && (CLEANED.has(record.status) || record.status === 'error' || record.status === 'restoring') ? h(Button, {
800
+ variant: 'outline', size: 'sm', disabled: busy || transitioning, onClick: () => { void act('restore'); },
801
+ }, t('restore')) : null,
802
+ !CLEANED.has(record.status) || record.archivePending ? h('span', { title: protectedRecord ? t('deleteProtected') : undefined },
803
+ h(Button, {
804
+ variant: 'outline', size: 'sm', disabled: busy || transitioning || protectedRecord,
805
+ onClick: () => { void act('delete'); },
806
+ }, t('delete'))) : null));
807
+ }
808
+
809
+ function SettingsSection({ controller, useSessions, useSessionStatus, useWorkspaces, openSession, pickDirectory, close, t }) {
810
+ const state = useStore(controller.data);
811
+ const sessions = useSessions((snapshot) => snapshot.byId);
812
+ const statuses = useSessionStatus((snapshot) => snapshot);
813
+ const workspace = useWorkspaces((snapshot) => snapshot);
814
+ const pinned = React.useMemo(() => new Set(workspace.pinnedSessionIds), [workspace.pinnedSessionIds]);
815
+ const archived = React.useMemo(() => new Set(workspace.archivedSessionIds), [workspace.archivedSessionIds]);
816
+ const [directory, setDirectory] = React.useState('');
817
+ const [maximum, setMaximum] = React.useState('15');
818
+ const [busy, setBusy] = React.useState(false);
819
+ const [error, setError] = React.useState(null);
820
+ const saving = React.useRef(false);
821
+ const directoryDirty = React.useRef(false);
822
+ const maximumDirty = React.useRef(false);
823
+ const fieldId = React.useId();
824
+ React.useEffect(() => {
825
+ if (!state.config) return;
826
+ if (!directoryDirty.current) setDirectory(state.config.directory);
827
+ if (!maximumDirty.current) setMaximum(String(state.config.maxRetained));
828
+ }, [state.config]);
829
+ React.useEffect(() => {
830
+ void controller.refresh(true);
831
+ // Only the visible management page periodically checks external Git changes.
832
+ const timer = window.setInterval(() => {
833
+ if (document.visibilityState === 'visible') void controller.refresh(true);
834
+ }, 30000);
835
+ return () => window.clearInterval(timer);
836
+ }, [controller]);
837
+ const save = async (field, picked) => {
838
+ if (saving.current || !state.config) return;
839
+ const next = field === 'directory' ? (picked ?? directory).trim() : Number(maximum);
840
+ if (field === 'directory' ? next === '' : !Number.isSafeInteger(next) || next < 1) {
841
+ setError(t(field === 'directory' ? 'directoryRequired' : 'invalidMaximum'));
842
+ return;
843
+ }
844
+ setError(null);
845
+ if (field === 'directory') setDirectory(next);
846
+ else setMaximum(String(next));
847
+ if (next === state.config[field]) {
848
+ if (field === 'directory') directoryDirty.current = false;
849
+ else maximumDirty.current = false;
850
+ return;
851
+ }
852
+ saving.current = true;
853
+ setBusy(true);
854
+ setError(null);
855
+ try {
856
+ await controller.call('configure', { [field]: next, expectedRevision: controller.data.getSnapshot().revision });
857
+ if (field === 'directory') directoryDirty.current = false;
858
+ else maximumDirty.current = false;
859
+ await controller.refresh(true);
860
+ } catch (reason) {
861
+ setError(messageOf(reason));
862
+ } finally {
863
+ saving.current = false;
864
+ setBusy(false);
865
+ }
866
+ };
867
+ const groups = React.useMemo(() => {
868
+ const result = new Map();
869
+ for (const record of state.records) {
870
+ const key = record.repositoryRoot || record.sourceRoot;
871
+ if (!result.has(key)) result.set(key, []);
872
+ result.get(key).push(record);
873
+ }
874
+ return [...result];
875
+ }, [state.records]);
876
+ const canPick = state.directoryPickerKind === 'native' || typeof globalThis.__DSH_DIRECTORY_PICKER__?.pick === 'function';
877
+ return h('div', { className: `${PREFIX}-section` },
878
+ h('div', { className: `${PREFIX}-setting` },
879
+ h('div', { className: `${PREFIX}-settingLabel` },
880
+ h('label', { className: `${PREFIX}-title`, htmlFor: `${fieldId}-directory` }, t('directory')),
881
+ h('div', { className: `${PREFIX}-help`, id: `${fieldId}-directory-help` }, t('directoryHelp'))),
882
+ h('div', { className: `${PREFIX}-field` },
883
+ h('input', {
884
+ id: `${fieldId}-directory`, className: `${PREFIX}-input`, value: directory,
885
+ disabled: busy || !state.config, 'aria-describedby': `${fieldId}-directory-help`,
886
+ onChange: (event) => { directoryDirty.current = true; setDirectory(event.target.value); },
887
+ onBlur: () => { void save('directory'); },
888
+ onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void save('directory'); } },
889
+ }),
890
+ canPick ? h(Button, {
891
+ variant: 'outline', size: 'sm', disabled: busy || !state.config,
892
+ onClick: async () => {
893
+ setError(null);
894
+ try {
895
+ const path = await pickDirectory();
896
+ if (path !== null) { directoryDirty.current = true; setDirectory(path); await save('directory', path); }
897
+ } catch (reason) { setError(messageOf(reason)); }
898
+ },
899
+ }, t('chooseDirectory')) : null)),
900
+ h('div', { className: `${PREFIX}-setting` },
901
+ h('div', { className: `${PREFIX}-settingLabel` },
902
+ h('label', { className: `${PREFIX}-title`, htmlFor: `${fieldId}-maximum` }, t('maxRetained')),
903
+ h('div', { className: `${PREFIX}-help`, id: `${fieldId}-maximum-help` }, t('retentionHelp'))),
904
+ h('input', {
905
+ id: `${fieldId}-maximum`, className: `${PREFIX}-input ${PREFIX}-number`, value: maximum,
906
+ type: 'number', min: 1, step: 1, disabled: busy || !state.config,
907
+ 'aria-describedby': `${fieldId}-maximum-help`,
908
+ onChange: (event) => { maximumDirty.current = true; setMaximum(event.target.value); },
909
+ onBlur: () => { void save('maxRetained'); },
910
+ onKeyDown: (event) => { if (event.key === 'Enter') { event.preventDefault(); void save('maxRetained'); } },
911
+ })),
912
+ error || state.error ? h('div', { className: `${PREFIX}-error`, role: 'alert' }, error || state.error) : null,
913
+ h('div', { className: `${PREFIX}-toolbar` }, h('div', { className: `${PREFIX}-title` }, t('managed')),
914
+ h(Button, { variant: 'outline', size: 'sm', disabled: state.phase === 'loading', onClick: () => { void controller.refresh(true); } }, t('refresh'))),
915
+ state.records.length === 0 ? h('div', { className: `${PREFIX}-help`, role: 'status' }, t(state.phase === 'loading' ? 'loading' : 'empty')) : null,
916
+ groups.map(([repository, records]) => h('section', { key: repository },
917
+ h('h3', { className: `${PREFIX}-repo`, title: repository }, basename(repository)),
918
+ records.map((record) => h(RecordRow, { key: record.id, record, sessions, statuses, pinned, archived, controller, openSession, close, t })))));
919
+ }
920
+
921
+ return {
922
+ inject: ['slots', 'locale', 'connection', 'conversation', 'uiWorkspace', 'workspaces'],
923
+ apply(ctx) {
924
+ for (const [locale, messages] of Object.entries(COPY)) {
925
+ ctx.effect(() => ctx.locale.register(NS, locale, messages));
926
+ }
927
+ const controller = createController(ctx);
928
+ const t = ctx.locale.bind(NS);
929
+ ctx.effect(() => () => controller.dispose());
930
+ ctx.effect(() => installSendGate(ctx, controller));
931
+ installOpenInAdapter(ctx);
932
+ ctx.on('connection/reset', () => controller.reset());
933
+ const injected = () => ({ controller });
934
+ ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
935
+ name: 'conversation.hero.options', id: PREFIX, order: 100, locale: NS, inject: injected,
936
+ }, WorktreeOptions));
937
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
938
+ name: 'shell.overlay', id: `${PREFIX}-dialogs`, order: 1100, locale: NS, inject: injected,
939
+ }, GlobalOverlay));
940
+ ctx.slots.inject(ACTION_SLOT, () => ctx.slots.register({
941
+ name: ACTION_SLOT, id: `${PREFIX}-marker`, order: 1100, locale: NS, inject: injected,
942
+ }, WorktreeMarker));
943
+ ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
944
+ name: 'sidebar.session.row.hover', id: `${PREFIX}-info`, order: 100, locale: NS, inject: injected,
945
+ }, WorktreeHover));
946
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
947
+ name: 'settings.section', id: 'worktrees', order: 250, label: () => t('nav'), locale: NS,
948
+ inject: () => ({
949
+ controller,
950
+ openSession: (sessionId) => ctx.get('uiWorkspace').openSession(sessionId),
951
+ pickDirectory: () => globalThis.__DSH_DIRECTORY_PICKER__?.pick
952
+ ? globalThis.__DSH_DIRECTORY_PICKER__.pick() : ctx.get('uiWorkspace').pickDirectory(),
953
+ }),
954
+ }, SettingsSection));
955
+ },
956
+ };
957
+ },
958
+ });