dsh-sidebar-row-menu 1.3.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * 会话行:直接展开该行内置的「…」菜单
11
11
  * 重命名、分叉、置顶、归档、复制 Session ID 等条目由各自注册进
12
12
  * `sidebar.workspaces.session.menu.item` 的插件提供,本插件不复制、不硬编码。
13
- * 本插件只往这个 slot 里追加自己的条目(VS Code / 资源管理器 / 复制会话链接),
13
+ * 本插件只往这个 slot 里追加自己的条目(VS Code / 资源管理器),
14
14
  * 所以它们既出现在「…」菜单里,也自动出现在右键菜单里。
15
15
  *
16
16
  * 设计要点:
@@ -44,31 +44,76 @@ window.__ModuleLoader__.load({
44
44
  var module = { exports: {} };
45
45
  var exports = module.exports;
46
46
 
47
- const { useCallback, useState } = require('react');
47
+ const {
48
+ Fragment,
49
+ useEffect,
50
+ useLayoutEffect,
51
+ useRef,
52
+ useState,
53
+ useSyncExternalStore,
54
+ } = require('react');
48
55
  const { jsx } = require('react/jsx-runtime');
49
56
  const { createRoot } = require('react-dom/client');
50
57
  const {
51
- Menu,
52
58
  MenuItemButton,
59
+ MenuSurface,
60
+ Modal,
61
+ Button,
62
+ Switch,
63
+ Toast,
53
64
  writeClipboard,
54
65
  IconEditOutlineRegular,
55
66
  IconTrashOutlineRegular,
56
- IconCodeOutlineRegular,
57
- IconFolderOpenRegular,
58
67
  IconProjectAddOutlineRegular,
59
68
  IconNewChatOutlineRegular,
60
- IconLinkOutlineRegular,
69
+ IconWarningOutlineRegular,
61
70
  IconCopyOutlineRegular,
71
+ IconChevronRightOutlineRegular,
72
+ IconRightUpOutlineRegular,
73
+ IconTriangleRightFillRegular,
62
74
  } = require('@deepseek-ai/dsh-client-ui-primitives');
63
75
 
64
76
  /** 宿主路由(与 lib/index.js 保持一致)。 */
65
77
  const OPEN_ENDPOINT = '/dsh-sidebar-row-menu/open';
66
78
  /** 运行时探测用标记属性(也是离线冒烟测试的抓手)。 */
67
79
  const MENU_ATTR = 'data-dsh-sidebar-row-menu';
80
+ /** 复制反馈的官方 Toast 挂载点。 */
81
+ const TOAST_ATTR = 'data-dsh-sidebar-row-menu-toast';
68
82
  /** 本地化命名空间。 */
69
83
  const NS = 'sidebar-row-menu';
70
84
  /** 内置「…」菜单的扩展点;条目由各插件自行注册。 */
71
85
  const SESSION_MENU_SLOT = 'sidebar.workspaces.session.menu.item';
86
+ /** jsx(type, props) 不接受第三个 children 参数,这里统一折算成 props.children。 */
87
+ function h(type, props, ...children) {
88
+ if (children.length === 0) return jsx(type, props);
89
+ return jsx(type, { ...props, children: children.length === 1 ? children[0] : children });
90
+ }
91
+ /** 永久删除会话的宿主端点(只允许删除已归档会话,见本文件条目)。 */
92
+ const DELETE_ENDPOINT = '/dsh-sidebar-row-menu/session/delete';
93
+ /** 官方 open-in-app 的三条路由;用文档相对路径让浏览器自带认证。 */
94
+ const APPS_ROUTE = 'open-in-app/apps';
95
+ const OPEN_ROUTE = 'open-in-app/open';
96
+ const ICON_ROUTE = 'open-in-app/icon/';
97
+ /** 菜单项开关(浏览器 localStorage,跟着 profile 走)。 */
98
+ const ITEMS_KEY = 'dsh.sidebar-row-menu.items.v1';
99
+ const OPEN_WITH_CHILDREN_KEY = 'dsh.sidebar-row-menu.open-with.v1';
100
+ /** 置顶到一级菜单的应用 id;未写过时按「编辑器优先」自动挑一个。 */
101
+ const OPEN_WITH_PINNED_KEY = 'dsh.sidebar-row-menu.open-with.pinned.v1';
102
+ const PIN_PREFERENCE = [
103
+ 'vscode', 'vscodeinsiders', 'cursor', 'windsurf', 'zed', 'sublimetext',
104
+ 'intellij', 'pycharm', 'webstorm', 'phpstorm', 'goland', 'rider', 'rustrover',
105
+ 'androidstudio', 'xcode',
106
+ ];
107
+ const ITEM_KEYS = ['openWith', 'deleteSession', 'deleteArchived'];
108
+ /**
109
+ * dsh-session-menu 注册的同一能力条目 id。检测到对方存在时,本插件对应项
110
+ * 自动隐藏,避免同一份菜单里出现两条一模一样的动作。
111
+ */
112
+ const FOREIGN_ITEMS = {
113
+ openWith: 'session-menu-open-with',
114
+ deleteSession: 'session-menu-delete',
115
+ deleteArchived: 'session-menu-delete-archived',
116
+ };
72
117
 
73
118
  /* ------------------------------------------------------------ 文案 */
74
119
 
@@ -78,7 +123,6 @@ window.__ModuleLoader__.load({
78
123
  vscode: '使用 VS Code 打开',
79
124
  explorer: '在资源管理器中打开',
80
125
  copyPath: '复制工作区路径',
81
- copyLink: '复制会话链接',
82
126
  openInTab: '在新标签中打开',
83
127
  deleteWorkspace: '删除工作区',
84
128
  copied: '已复制',
@@ -86,6 +130,44 @@ window.__ModuleLoader__.load({
86
130
  noPath: '没有可用的目录路径',
87
131
  openFailed: '打开失败:宿主没有找到对应程序',
88
132
  actionFailed: '操作失败,请稍后重试',
133
+ openWith: '打开方式',
134
+ openWithApp: '用 {name} 打开',
135
+ deleteSession: '永久删除会话',
136
+ deleteArchived: '删除所有已归档会话',
137
+ 'settings.title': '行菜单',
138
+ 'settings.openWith.label': '打开方式',
139
+ 'settings.openWith.hint': '在子菜单里选择编辑器、终端或文件管理器打开工作区或会话目录;置顶的应用直接显示在菜单一级。',
140
+ 'settings.deleteSession.label': '永久删除会话',
141
+ 'settings.deleteSession.hint': '在已归档会话的菜单里永久删除这一个会话及其日志。',
142
+ 'settings.deleteArchived.label': '删除所有已归档会话',
143
+ 'settings.deleteArchived.hint': '在已归档会话的菜单里一次清理全部归档会话。',
144
+ 'settings.children': '选择要显示的应用',
145
+ 'settings.pin': '置顶',
146
+ 'settings.pinned': '已置顶',
147
+ 'settings.pinTitle': '直接显示在会话右键菜单的一级',
148
+ 'settings.conflict': '已由 dsh-session-menu 提供,此项自动停用',
149
+ 'settings.conflictNote': '检测到 dsh-session-menu:重复的菜单项已自动隐藏。',
150
+ 'settings.childLocked': '需先开启「{name}」',
151
+ 'settings.appsLoading': '正在加载应用…',
152
+ 'settings.appsUnavailable': '宿主没有提供可用的外部应用',
153
+ 'settings.appsError': '应用列表加载失败,请稍后重试',
154
+ 'settings.appsAllOff': '所有应用都被二级开关隐藏了',
155
+ 'settings.expand': '展开应用列表',
156
+ 'settings.collapse': '收起应用列表',
157
+ 'delete.title': '永久删除会话?',
158
+ 'delete.description': '会话「{name}」的日志将从磁盘永久删除,此操作无法撤销。',
159
+ 'delete.archivedTitle': '删除所有已归档会话?',
160
+ 'delete.archivedDescription': '将永久删除 {n} 个已归档会话及其日志,此操作无法撤销。',
161
+ 'delete.confirm': '永久删除',
162
+ 'delete.archivedConfirm': '全部删除',
163
+ 'delete.cancel': '取消',
164
+ 'delete.pending': '正在删除…',
165
+ 'delete.archivedPending': '正在批量删除…',
166
+ 'delete.archivedPartial': '已删除 {done} 个,失败 {failed} 个。',
167
+ 'delete.failed': '删除失败:{message}',
168
+ 'delete.unavailable': '删除服务不可用',
169
+ 'delete.done': '已删除会话',
170
+ 'delete.doneMany': '已删除 {n} 个会话',
89
171
  };
90
172
  const en = {
91
173
  newSession: 'New session',
@@ -93,7 +175,6 @@ window.__ModuleLoader__.load({
93
175
  vscode: 'Open in VS Code',
94
176
  explorer: 'Reveal in File Explorer',
95
177
  copyPath: 'Copy workspace path',
96
- copyLink: 'Copy session link',
97
178
  openInTab: 'Open in new tab',
98
179
  deleteWorkspace: 'Delete workspace',
99
180
  copied: 'Copied',
@@ -101,7 +182,87 @@ window.__ModuleLoader__.load({
101
182
  noPath: 'No usable directory path',
102
183
  openFailed: 'Open failed: no handler found on the host',
103
184
  actionFailed: 'Action failed, please retry',
185
+ openWith: 'Open with',
186
+ openWithApp: 'Open with {name}',
187
+ deleteSession: 'Delete session permanently',
188
+ deleteArchived: 'Delete all archived sessions',
189
+ 'settings.title': 'Row menu',
190
+ 'settings.openWith.label': 'Open with',
191
+ 'settings.openWith.hint': 'Pick an editor, terminal or file manager for workspace or session directories; pinned apps show up directly in the menu.',
192
+ 'settings.deleteSession.label': 'Delete session permanently',
193
+ 'settings.deleteSession.hint': 'Permanently delete a single archived session and its transcript from an archived row.',
194
+ 'settings.deleteArchived.label': 'Delete all archived sessions',
195
+ 'settings.deleteArchived.hint': 'Clean up every archived session from an archived session row.',
196
+ 'settings.children': 'Applications to show',
197
+ 'settings.pin': 'Pin',
198
+ 'settings.pinned': 'Pinned',
199
+ 'settings.pinTitle': 'Show directly at the top level of the session menu',
200
+ 'settings.conflict': 'Provided by dsh-session-menu; disabled automatically',
201
+ 'settings.conflictNote': 'dsh-session-menu detected: duplicated entries are hidden automatically.',
202
+ 'settings.childLocked': 'Enable "{name}" first',
203
+ 'settings.appsLoading': 'Loading applications…',
204
+ 'settings.appsUnavailable': 'The host exposed no external applications',
205
+ 'settings.appsError': 'Could not load the application list, please retry',
206
+ 'settings.appsAllOff': 'Every application is hidden by the switches above',
207
+ 'settings.expand': 'Show applications',
208
+ 'settings.collapse': 'Hide applications',
209
+ 'delete.title': 'Delete this session permanently?',
210
+ 'delete.description': 'The log of "{name}" will be removed from disk. This cannot be undone.',
211
+ 'delete.archivedTitle': 'Delete all archived sessions?',
212
+ 'delete.archivedDescription': 'Permanently delete {n} archived sessions and their logs. This cannot be undone.',
213
+ 'delete.confirm': 'Delete permanently',
214
+ 'delete.archivedConfirm': 'Delete all',
215
+ 'delete.cancel': 'Cancel',
216
+ 'delete.pending': 'Deleting…',
217
+ 'delete.archivedPending': 'Deleting the archived sessions…',
218
+ 'delete.archivedPartial': 'Deleted {done}, failed {failed}.',
219
+ 'delete.failed': 'Delete failed: {message}',
220
+ 'delete.unavailable': 'The delete service is unavailable',
221
+ 'delete.done': 'Session deleted',
222
+ 'delete.doneMany': 'Deleted {n} sessions',
223
+ };
224
+
225
+ /** 官方 open-in-app 的应用 id → 显示名。官方 locale 没有这张表。 */
226
+ const APP_NAMES = {
227
+ vscode: ['Visual Studio Code', 'Visual Studio Code'],
228
+ vscodeinsiders: ['VS Code Insiders', 'VS Code Insiders'],
229
+ cursor: ['Cursor', 'Cursor'],
230
+ windsurf: ['Windsurf', 'Windsurf'],
231
+ zed: ['Zed', 'Zed'],
232
+ sublimetext: ['Sublime Text', 'Sublime Text'],
233
+ xcode: ['Xcode', 'Xcode'],
234
+ androidstudio: ['Android Studio', 'Android Studio'],
235
+ intellij: ['IntelliJ IDEA', 'IntelliJ IDEA'],
236
+ pycharm: ['PyCharm', 'PyCharm'],
237
+ webstorm: ['WebStorm', 'WebStorm'],
238
+ phpstorm: ['PhpStorm', 'PhpStorm'],
239
+ goland: ['GoLand', 'GoLand'],
240
+ rider: ['Rider', 'Rider'],
241
+ rustrover: ['RustRover', 'RustRover'],
242
+ fork: ['Fork', 'Fork'],
243
+ sourcetree: ['Sourcetree', 'Sourcetree'],
244
+ github: ['GitHub Desktop', 'GitHub Desktop'],
245
+ tower: ['Tower', 'Tower'],
246
+ gitkraken: ['GitKraken', 'GitKraken'],
247
+ smartgit: ['SmartGit', 'SmartGit'],
248
+ sublimemerge: ['Sublime Merge', 'Sublime Merge'],
249
+ ghostty: ['Ghostty', 'Ghostty'],
250
+ warp: ['Warp', 'Warp'],
251
+ iterm: ['iTerm', 'iTerm'],
252
+ kitty: ['kitty', 'kitty'],
253
+ terminal: ['终端', 'Terminal'],
254
+ windowsterminal: ['Windows Terminal', 'Windows Terminal'],
255
+ gitbash: ['Git Bash', 'Git Bash'],
256
+ gnometerminal: ['终端', 'Terminal'],
257
+ konsole: ['Konsole', 'Konsole'],
258
+ finder: ['访达', 'Finder'],
259
+ explorer: ['文件资源管理器', 'File Explorer'],
260
+ filemanager: ['文件管理器', 'Files'],
104
261
  };
262
+ for (const [id, names] of Object.entries(APP_NAMES)) {
263
+ zh[`app.${id}`] = names[0];
264
+ en[`app.${id}`] = names[1];
265
+ }
105
266
 
106
267
  /* -------------------------------------------------- React fiber 取值 */
107
268
 
@@ -205,29 +366,48 @@ window.__ModuleLoader__.load({
205
366
 
206
367
  /* ------------------------------------------------------------ 小工具 */
207
368
 
208
- /** 小提示条:复制 / 打开失败之类给一句反馈。 */
209
- function toast(message) {
369
+ /**
370
+ * 轻提示:直接用官方 Toast primitive(与内置「复制 Session ID」同款、
371
+ * 同一个底部居中 portal),不再自绘 DOM 气泡。
372
+ */
373
+ let toastHost = null;
374
+ let toastRoot = null;
375
+
376
+ function closeToast() {
377
+ const host = toastHost;
378
+ const root = toastRoot;
379
+ toastHost = null;
380
+ toastRoot = null;
381
+ if (root !== null) {
382
+ try {
383
+ root.unmount();
384
+ } catch {
385
+ }
386
+ }
387
+ if (host !== null) host.remove();
388
+ }
389
+
390
+ function toast(message, tone) {
210
391
  if (document.body === null) return;
211
- const el = document.createElement('div');
212
- el.textContent = message;
213
- el.style.cssText = [
214
- 'position:fixed',
215
- 'left:50%',
216
- 'bottom:28px',
217
- 'transform:translateX(-50%)',
218
- 'z-index:2147483647',
219
- 'padding:8px 14px',
220
- 'border-radius:8px',
221
- 'font-size:13px',
222
- 'font-family:inherit',
223
- 'background:var(--dsw-alias-button-elevated-fill,#2f2f31)',
224
- 'color:var(--dsw-alias-label-primary,#ececec)',
225
- 'box-shadow:0 6px 20px rgba(0,0,0,.35)',
226
- 'pointer-events:none',
227
- 'white-space:nowrap',
228
- ].join(';');
229
- document.body.appendChild(el);
230
- window.setTimeout(() => el.remove(), 2600);
392
+ closeToast();
393
+ const host = document.createElement('div');
394
+ host.setAttribute(TOAST_ATTR, '');
395
+ host.style.cssText =
396
+ 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none';
397
+ document.body.appendChild(host);
398
+ const root = createRoot(host);
399
+ toastHost = host;
400
+ toastRoot = root;
401
+ const props = {
402
+ text: message,
403
+ onDone: () => window.setTimeout(closeToast, 0),
404
+ };
405
+ if (tone === 'success') props.tone = 'success';
406
+ if (tone === 'warning') {
407
+ props.tone = 'warning';
408
+ props.icon = jsx(IconWarningOutlineRegular, {});
409
+ }
410
+ root.render(jsx(Toast, props));
231
411
  }
232
412
 
233
413
  /** 取客户端服务;cordis 对未 inject 的服务在属性访问时也会抛错,全部兜住。 */
@@ -277,20 +457,13 @@ window.__ModuleLoader__.load({
277
457
  async function copyText(text, t) {
278
458
  try {
279
459
  const ok = await writeClipboard(text);
280
- toast(ok ? t('copied') : t('copyFailed'));
460
+ toast(ok ? t('copied') : t('copyFailed'), ok ? 'success' : 'warning');
281
461
  } catch (error) {
282
462
  console.warn('[dsh-sidebar-row-menu] clipboard failed:', error);
283
- toast(t('copyFailed'));
463
+ toast(t('copyFailed'), 'warning');
284
464
  }
285
465
  }
286
466
 
287
- /** ?session=<id> 深链接(与 dsh-workspace-menu / 内置 toast 的格式一致)。 */
288
- function deepLink(sessionId) {
289
- const url = new URL(window.location.href);
290
- url.searchParams.set('session', sessionId);
291
- return url.toString();
292
- }
293
-
294
467
  function workspaceCwd(ctx, group) {
295
468
  if (typeof group?.cwd === 'string' && group.cwd !== '') return group.cwd;
296
469
  const workspaces = serviceOf(ctx, 'workspaces');
@@ -311,89 +484,934 @@ window.__ModuleLoader__.load({
311
484
  return typeof item?.path === 'string' ? item.path : '';
312
485
  }
313
486
 
314
- /* -------------------------------------------------------------- 菜单 */
487
+ /* ------------------------------------------------ 菜单项设置(localStorage) */
488
+
489
+ function readStoredItems() {
490
+ const defaults = {};
491
+ for (const key of ITEM_KEYS) defaults[key] = true;
492
+ try {
493
+ const raw = globalThis.localStorage?.getItem(ITEMS_KEY);
494
+ if (typeof raw !== 'string' || raw === '') return defaults;
495
+ const parsed = JSON.parse(raw);
496
+ if (parsed === null || typeof parsed !== 'object') return defaults;
497
+ for (const key of ITEM_KEYS) {
498
+ if (typeof parsed[key] === 'boolean') defaults[key] = parsed[key];
499
+ }
500
+ } catch {
501
+ /* 坏数据回退默认,不阻断菜单 */
502
+ }
503
+ return defaults;
504
+ }
505
+
506
+ let itemSettings = readStoredItems();
507
+ const itemListeners = new Set();
508
+
509
+ function publishItems() {
510
+ for (const listener of itemListeners) {
511
+ try {
512
+ listener();
513
+ } catch {
514
+ /* ignore */
515
+ }
516
+ }
517
+ }
518
+
519
+ function subscribeItems(listener) {
520
+ itemListeners.add(listener);
521
+ return () => {
522
+ itemListeners.delete(listener);
523
+ };
524
+ }
525
+
526
+ function itemsSnapshot() {
527
+ return itemSettings;
528
+ }
529
+
530
+ function useItems() {
531
+ return useSyncExternalStore(subscribeItems, itemsSnapshot, itemsSnapshot);
532
+ }
533
+
534
+ function setItem(key, enabled) {
535
+ if (!ITEM_KEYS.includes(key)) return;
536
+ itemSettings = { ...itemSettings, [key]: enabled === true };
537
+ try {
538
+ globalThis.localStorage?.setItem(ITEMS_KEY, JSON.stringify(itemSettings));
539
+ } catch {
540
+ /* 隐私模式写不进去也照样生效到本次会话 */
541
+ }
542
+ publishItems();
543
+ }
544
+
545
+ function itemOn(key) {
546
+ return itemSettings[key] !== false;
547
+ }
548
+
549
+ /* ------------------------------------- 打开方式:应用列表与二级开关 */
550
+
551
+ function readStoredChildren() {
552
+ try {
553
+ const raw = globalThis.localStorage?.getItem(OPEN_WITH_CHILDREN_KEY);
554
+ if (typeof raw !== 'string' || raw === '') return {};
555
+ const parsed = JSON.parse(raw);
556
+ if (parsed !== null && typeof parsed === 'object') return parsed;
557
+ } catch {
558
+ /* ignore */
559
+ }
560
+ return {};
561
+ }
562
+
563
+ let childSettings = readStoredChildren();
564
+ const childListeners = new Set();
565
+
566
+ function publishChildren() {
567
+ for (const listener of childListeners) {
568
+ try {
569
+ listener();
570
+ } catch {
571
+ /* ignore */
572
+ }
573
+ }
574
+ }
575
+
576
+ function subscribeChildren(listener) {
577
+ childListeners.add(listener);
578
+ return () => {
579
+ childListeners.delete(listener);
580
+ };
581
+ }
582
+
583
+ function childrenSnapshot() {
584
+ return childSettings;
585
+ }
586
+
587
+ function useChildren() {
588
+ return useSyncExternalStore(subscribeChildren, childrenSnapshot, childrenSnapshot);
589
+ }
590
+
591
+ function setChild(appId, enabled) {
592
+ childSettings = { ...childSettings, [appId]: enabled === true };
593
+ try {
594
+ globalThis.localStorage?.setItem(OPEN_WITH_CHILDREN_KEY, JSON.stringify(childSettings));
595
+ } catch {
596
+ /* ignore */
597
+ }
598
+ publishChildren();
599
+ }
600
+
601
+ function childOn(appId) {
602
+ return childSettings[appId] !== false;
603
+ }
604
+
605
+ /* ------------------------------ 打开方式:一级菜单置顶(常用应用) */
606
+
607
+ function readStoredPinned() {
608
+ try {
609
+ const raw = globalThis.localStorage?.getItem(OPEN_WITH_PINNED_KEY);
610
+ if (typeof raw !== 'string' || raw === '') return null;
611
+ const parsed = JSON.parse(raw);
612
+ if (!Array.isArray(parsed)) return null;
613
+ return parsed.filter((id) => typeof id === 'string' && id !== '');
614
+ } catch {
615
+ /* 坏数据按「未配置」处理,回到默认置顶 */
616
+ }
617
+ return null;
618
+ }
619
+
620
+ let pinnedSettings = readStoredPinned();
621
+ const pinnedListeners = new Set();
622
+
623
+ function publishPinned() {
624
+ for (const listener of pinnedListeners) {
625
+ try {
626
+ listener();
627
+ } catch {
628
+ /* ignore */
629
+ }
630
+ }
631
+ }
632
+
633
+ function subscribePinned(listener) {
634
+ pinnedListeners.add(listener);
635
+ return () => {
636
+ pinnedListeners.delete(listener);
637
+ };
638
+ }
639
+
640
+ function pinnedSnapshot() {
641
+ return pinnedSettings;
642
+ }
643
+
644
+ function usePinned() {
645
+ return useSyncExternalStore(subscribePinned, pinnedSnapshot, pinnedSnapshot);
646
+ }
315
647
 
316
648
  /**
317
- * 一次右键菜单的 React 组件。
318
- * 用 portal 模式 + 显式锚点矩形,把菜单钉在鼠标位置;Menu 自己负责
319
- * 视口夹取、键盘导航和「点外部 / Esc 关闭」。
649
+ * 实际置顶的应用:用户没配置过时自动挑选 —— 有已知编辑器就置顶第一个
650
+ * 编辑器(通常是 VS Code),否则置顶官方列表里的第一个应用。
320
651
  */
321
- function RowContextMenu({ x, y, items, onSelect, onClose }) {
322
- const getAnchorRect = useCallback(
323
- () => ({ left: x, right: x, top: y, bottom: y, width: 0, height: 0 }),
324
- [x, y],
325
- );
326
- return jsx(Menu, {
327
- open: true,
328
- anchor: null,
329
- items,
330
- portal: true,
331
- autoFocus: true,
332
- align: 'start',
333
- side: 'bottom',
334
- getAnchorRect,
335
- onSelect,
336
- onClose,
337
- });
652
+ function effectivePinned(ids) {
653
+ if (pinnedSettings !== null) {
654
+ return pinnedSettings.filter((id) => ids.includes(id));
655
+ }
656
+ const preferred = ids.find((id) => PIN_PREFERENCE.includes(id));
657
+ if (preferred !== undefined) return [preferred];
658
+ return ids.slice(0, 1);
338
659
  }
339
660
 
340
- /** 在 (x, y) 处挂出菜单。 */
341
- function showMenu(items, onSelect, x, y) {
342
- closeMenu();
343
- menuHost = document.createElement('div');
344
- menuHost.setAttribute(MENU_ATTR, '1');
345
- menuHost.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647';
346
- document.body.appendChild(menuHost);
347
- menuRoot = createRoot(menuHost);
348
- menuRoot.render(jsx(RowContextMenu, {
349
- x,
350
- y,
351
- items,
352
- onSelect: (id) => {
353
- closeMenu();
354
- onSelect(id);
661
+ function setPinned(ids) {
662
+ pinnedSettings = [...new Set(ids.filter((id) => typeof id === 'string' && id !== ''))];
663
+ try {
664
+ globalThis.localStorage?.setItem(OPEN_WITH_PINNED_KEY, JSON.stringify(pinnedSettings));
665
+ } catch {
666
+ /* ignore */
667
+ }
668
+ publishPinned();
669
+ }
670
+
671
+ function togglePinned(appId, ids) {
672
+ const current = effectivePinned(ids);
673
+ setPinned(current.includes(appId) ? current.filter((id) => id !== appId) : [...current, appId]);
674
+ }
675
+
676
+ function appLabel(appId, t) {
677
+ const key = `app.${appId}`;
678
+ const value = t(key);
679
+ return value === key ? appId : value;
680
+ }
681
+
682
+ /** 应用图标;宿主没有图标时 404,隐藏 img 但不留空洞。 */
683
+ function appIcon(appId) {
684
+ return jsx('img', {
685
+ className: 'dsrm-app-icon',
686
+ src: ICON_ROUTE + encodeURIComponent(appId),
687
+ alt: '',
688
+ onError: (event) => {
689
+ event.currentTarget.style.visibility = 'hidden';
355
690
  },
356
- onClose: () => closeMenu(),
357
- }));
691
+ });
358
692
  }
359
693
 
360
- function localeOf(ctx) {
694
+ /** 官方 /open-in-app/apps 的客户端缓存;菜单和设置页共用。 */
695
+ let appsState = { status: 'idle', ids: [], at: 0 };
696
+ const appsListeners = new Set();
697
+
698
+ function publishApps(next) {
699
+ appsState = next;
700
+ for (const listener of appsListeners) {
701
+ try {
702
+ listener();
703
+ } catch {
704
+ /* ignore */
705
+ }
706
+ }
707
+ }
708
+
709
+ function subscribeApps(listener) {
710
+ appsListeners.add(listener);
711
+ return () => {
712
+ appsListeners.delete(listener);
713
+ };
714
+ }
715
+
716
+ function appsSnapshot() {
717
+ return appsState;
718
+ }
719
+
720
+ function useApps() {
721
+ return useSyncExternalStore(subscribeApps, appsSnapshot, appsSnapshot);
722
+ }
723
+
724
+ function ensureApps() {
725
+ if (typeof fetch !== 'function') {
726
+ if (appsState.status !== 'error') publishApps({ status: 'error', ids: [], at: Date.now() });
727
+ return;
728
+ }
729
+ const now = Date.now();
730
+ if (appsState.status === 'loading') return;
731
+ if (appsState.status === 'ready' && now - appsState.at < 60000) return;
732
+ if (appsState.status === 'error' && now - appsState.at < 10000) return;
733
+ const previous = appsState.ids;
734
+ publishApps({ status: 'loading', ids: previous, at: now });
735
+ fetch(APPS_ROUTE, { headers: { accept: 'application/json' } })
736
+ .then((response) => {
737
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
738
+ return response.json();
739
+ })
740
+ .then((payload) => {
741
+ const ids = Array.isArray(payload?.apps)
742
+ ? payload.apps.filter((id) => typeof id === 'string' && id !== '')
743
+ : [];
744
+ publishApps({ status: 'ready', ids: [...new Set(ids)], at: Date.now() });
745
+ })
746
+ .catch(() => publishApps({ status: 'error', ids: [], at: Date.now() }));
747
+ }
748
+
749
+ function openApplication(appId, path) {
750
+ if (typeof path !== 'string' || path === '') return Promise.resolve(false);
751
+ if (typeof fetch !== 'function') return Promise.resolve(false);
752
+ return fetch(OPEN_ROUTE, {
753
+ method: 'POST',
754
+ headers: { 'content-type': 'application/json' },
755
+ body: JSON.stringify({ app: appId, path }),
756
+ }).then(
757
+ (response) => response.ok,
758
+ () => false,
759
+ );
760
+ }
761
+
762
+ /* ------------------------------------------------ 与 dsh-session-menu 共存 */
763
+
764
+ /** 读 slot 注册项 id(不同 DSH 版本的 register 返回形状略有差异)。 */
765
+ function slotItemId(entry) {
766
+ const value = entry?.options?.id ?? entry?.id ?? entry?.entry?.id;
767
+ return typeof value === 'string' ? value : '';
768
+ }
769
+
770
+ function foreignItemIds() {
771
+ const slots = rootCtx?.slots;
772
+ const ids = new Set();
773
+ if (slots === null || slots === undefined || typeof slots.entriesOfSlot !== 'function') return ids;
361
774
  try {
362
- if (typeof ctx?.locale?.bind === 'function') return ctx.locale.bind(NS);
775
+ const entries = slots.entriesOfSlot(SESSION_MENU_SLOT);
776
+ if (Array.isArray(entries)) {
777
+ for (const entry of entries) {
778
+ const id = slotItemId(entry);
779
+ if (id !== '') ids.add(id);
780
+ }
781
+ }
363
782
  } catch (error) {
364
- console.warn('[dsh-sidebar-row-menu] locale bind failed:', error);
783
+ console.warn('[dsh-sidebar-row-menu] slot lookup failed:', error);
365
784
  }
366
- return (key) => key;
785
+ return ids;
367
786
  }
368
787
 
369
- /** 工作区行右键菜单(没有 slot 可复用,只能自己组)。 */
370
- function openWorkspaceMenu(props, x, y) {
371
- const ctx = rootCtx;
372
- if (ctx === null) return;
373
- const t = localeOf(ctx);
374
- const group = props.group ?? {};
375
- const workspaceId = typeof group.workspaceId === 'string' ? group.workspaceId : '';
376
- const cwd = workspaceCwd(ctx, group);
788
+ function takenOver(key) {
789
+ const id = FOREIGN_ITEMS[key];
790
+ return typeof id === 'string' && foreignItemIds().has(id);
791
+ }
377
792
 
378
- const tabs = serviceOf(ctx, 'projectTabs') ?? globalThis.__DSH_PROJECT_TABS__ ?? null;
379
- const canOpenInTab = tabs !== null && typeof tabs.openWorkspaceInTab === 'function' && workspaceId !== '';
793
+ /* ------------------------------------------------------ 已归档会话集合 */
380
794
 
381
- const items = [];
382
- if (typeof props.onCreate === 'function') {
383
- items.push({ id: 'new-session', label: t('newSession'), icon: jsx(IconNewChatOutlineRegular, {}) });
795
+ const EMPTY_IDS = new Set();
796
+ let archivedCache = { snapshot: undefined, ids: EMPTY_IDS };
797
+
798
+ function archivedStore() {
799
+ return serviceOf(rootCtx, 'workspaces')?.list ?? null;
800
+ }
801
+
802
+ function archivedSnapshot() {
803
+ const store = archivedStore();
804
+ const snapshot = store?.getSnapshot?.();
805
+ if (snapshot === null || snapshot === undefined) return EMPTY_IDS;
806
+ if (archivedCache.snapshot === snapshot) return archivedCache.ids;
807
+ const raw = Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [];
808
+ archivedCache = {
809
+ snapshot,
810
+ ids: new Set(raw.filter((id) => typeof id === 'string' && id !== '')),
811
+ };
812
+ return archivedCache.ids;
813
+ }
814
+
815
+ function subscribeArchived(listener) {
816
+ const store = archivedStore();
817
+ return typeof store?.subscribe === 'function' ? store.subscribe(listener) : () => {};
818
+ }
819
+
820
+ function useArchivedIds() {
821
+ return useSyncExternalStore(subscribeArchived, archivedSnapshot, archivedSnapshot);
822
+ }
823
+
824
+ /** 会话是否是主视图正打开的那个(删掉后需要清空主视图)。 */
825
+ function isMainSession(id) {
826
+ const snapshot = serviceOf(rootCtx, 'sessions')?.list?.getSnapshot?.();
827
+ const byId = snapshot?.byId;
828
+ if (byId === null || typeof byId !== 'object') return false;
829
+ for (const row of Object.values(byId)) {
830
+ if (row?.id === id && (row?.retainedBy?.mainView ?? 0) > 0) return true;
384
831
  }
385
- items.push({ id: 'rename', label: t('rename'), icon: jsx(IconEditOutlineRegular, {}) });
386
- items.push({ id: 'sep1', type: 'separator' });
387
- items.push({ id: 'vscode', label: t('vscode'), icon: jsx(IconCodeOutlineRegular, { size: 14 }) });
388
- items.push({ id: 'explorer', label: t('explorer'), icon: jsx(IconFolderOpenRegular, { size: 14 }) });
389
- items.push({ id: 'copy-path', label: t('copyPath'), icon: jsx(IconCopyOutlineRegular, { size: 14 }) });
390
- if (canOpenInTab) {
391
- items.push({ id: 'tab', label: t('openInTab'), icon: jsx(IconProjectAddOutlineRegular, { size: 14 }) });
832
+ return false;
833
+ }
834
+
835
+ /* ------------------------------------------------------ 删除确认对话框 */
836
+
837
+ let deleteRequest = null;
838
+ let deleteSeq = 0;
839
+ const deleteListeners = new Set();
840
+
841
+ function subscribeDelete(listener) {
842
+ deleteListeners.add(listener);
843
+ return () => {
844
+ deleteListeners.delete(listener);
845
+ };
846
+ }
847
+
848
+ function deleteSnapshot() {
849
+ return deleteRequest;
850
+ }
851
+
852
+ function useDeleteRequest() {
853
+ return useSyncExternalStore(subscribeDelete, deleteSnapshot, deleteSnapshot);
854
+ }
855
+
856
+ function publishDelete() {
857
+ for (const listener of deleteListeners) {
858
+ try {
859
+ listener();
860
+ } catch {
861
+ /* ignore */
862
+ }
863
+ }
864
+ }
865
+
866
+ function requestDelete(request) {
867
+ deleteSeq += 1;
868
+ deleteRequest = { ...request, seq: deleteSeq };
869
+ publishDelete();
870
+ }
871
+
872
+ function closeDelete() {
873
+ if (deleteRequest === null) return;
874
+ deleteRequest = null;
875
+ publishDelete();
876
+ }
877
+
878
+ function postDelete(ids) {
879
+ if (typeof fetch !== 'function') return Promise.resolve({ ok: false, deleted: 0, failed: ids.length, error: 'network' });
880
+ return fetch(DELETE_ENDPOINT, {
881
+ method: 'POST',
882
+ headers: { 'content-type': 'application/json' },
883
+ body: JSON.stringify({ sessionIds: ids }),
884
+ })
885
+ .then((response) => response.json().catch(() => null)
886
+ .then((payload) => (payload !== null && typeof payload === 'object'
887
+ ? payload
888
+ : { ok: false, deleted: 0, failed: ids.length, error: `HTTP ${response.status}` })))
889
+ .catch(() => ({ ok: false, deleted: 0, failed: ids.length, error: 'network' }));
890
+ }
891
+
892
+ /** shell.overlay 上的对话框宿主;没有请求时什么都不渲染。 */
893
+ function DeleteDialog({ t }) {
894
+ const request = useDeleteRequest();
895
+ if (request === null) return null;
896
+ return jsx(DeleteConfirmForm, { key: request.seq, request, t });
897
+ }
898
+
899
+ function DeleteConfirmForm({ request, t }) {
900
+ const [busy, setBusy] = useState(false);
901
+ const [error, setError] = useState(null);
902
+ const [partial, setPartial] = useState(null);
903
+ const bulk = request.mode === 'archived';
904
+ const ids = bulk ? request.sessionIds : [request.sessionId];
905
+ const close = () => {
906
+ if (!busy) closeDelete();
907
+ };
908
+ const confirm = () => {
909
+ if (busy) return;
910
+ setBusy(true);
911
+ setError(null);
912
+ setPartial(null);
913
+ const wasMain = ids.some((id) => isMainSession(id));
914
+ void (async () => {
915
+ try {
916
+ const result = await postDelete(ids);
917
+ if (result.ok !== true && (result.deleted ?? 0) === 0) {
918
+ const reason = typeof result.error === 'string' && result.error !== '' ? result.error : t('delete.unavailable');
919
+ throw new Error(reason);
920
+ }
921
+ if (wasMain) serviceOf(rootCtx, 'uiWorkspace')?.clearMain?.();
922
+ if ((result.failed ?? 0) > 0) {
923
+ setPartial({ done: result.deleted ?? 0, failed: result.failed });
924
+ return;
925
+ }
926
+ closeDelete();
927
+ toast(bulk ? t('delete.doneMany', { n: result.deleted ?? ids.length }) : t('delete.done'), 'success');
928
+ } catch (reason) {
929
+ setError(reason instanceof Error ? reason.message : String(reason));
930
+ } finally {
931
+ setBusy(false);
932
+ }
933
+ })();
934
+ };
935
+ return jsx(Modal, {
936
+ open: true,
937
+ onClose: close,
938
+ closeLabel: t('delete.cancel'),
939
+ title: bulk ? t('delete.archivedTitle') : t('delete.title'),
940
+ description: bulk
941
+ ? t('delete.archivedDescription', { n: ids.length })
942
+ : t('delete.description', { name: request.displayTitle ?? '' }),
943
+ footer: h(Fragment, null,
944
+ jsx(Button, { variant: 'outline', disabled: busy, onClick: close, children: t('delete.cancel') }),
945
+ jsx(Button, {
946
+ variant: 'outline',
947
+ disabled: busy,
948
+ onClick: confirm,
949
+ children: h('span', { className: 'dsrm-danger-label' }, bulk ? t('delete.archivedConfirm') : t('delete.confirm')),
950
+ })),
951
+ children: [
952
+ busy
953
+ ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'busy' }, bulk ? t('delete.archivedPending') : t('delete.pending'))
954
+ : null,
955
+ partial !== null
956
+ ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'partial' }, t('delete.archivedPartial', { done: partial.done, failed: partial.failed }))
957
+ : null,
958
+ error !== null
959
+ ? h('div', { className: 'dsrm-error-text', role: 'alert', key: 'error' }, t('delete.failed', { message: error }))
960
+ : null,
961
+ ],
962
+ });
963
+ }
964
+
965
+ /* ---------------------------------------------------------- 打开方式子菜单 */
966
+
967
+ /** 面板贴边时的位置:右侧放不下就翻到左边,并在视口内夹取。 */
968
+ function submenuGeometry(anchor, size) {
969
+ const margin = 12;
970
+ const gap = 6;
971
+ const width = Math.max(160, size?.width ?? 180);
972
+ const height = Math.max(0, size?.height ?? 0);
973
+ let left = anchor.right + gap;
974
+ if (left + width > window.innerWidth - margin) left = anchor.left - width - gap;
975
+ left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
976
+ let top = anchor.bottom - height - 4;
977
+ top = Math.max(margin, Math.min(top, window.innerHeight - height - margin));
978
+ return { left, top };
979
+ }
980
+
981
+ /** slot 注入的 useMenuOpenState 在不同版本里可能不是数组,宽松取关闭函数。 */
982
+ function menuCloser(useMenuOpenState) {
983
+ if (typeof useMenuOpenState !== 'function') return undefined;
984
+ const state = useMenuOpenState();
985
+ return Array.isArray(state) && typeof state[1] === 'function' ? state[1] : undefined;
986
+ }
987
+
988
+ /**
989
+ * 「打开方式」二级菜单:会话行和工作区行共用。
990
+ * 官方 MenuItemButton 没有子菜单,面板用 primitives.MenuSurface 保持官方外观。
991
+ */
992
+ function OpenWithSubmenu({ t, getCwd, closeParent, separatorBefore = false }) {
993
+ const children = useChildren();
994
+ const apps = useApps();
995
+ usePinned();
996
+ const [open, setOpen] = useState(false);
997
+ const [box, setBox] = useState(null);
998
+ const hostRef = useRef(null);
999
+ const closeTimer = useRef(0);
1000
+
1001
+ useEffect(() => {
1002
+ if (open) ensureApps();
1003
+ }, [open]);
1004
+
1005
+ useEffect(() => {
1006
+ if (!open) return undefined;
1007
+ const dismiss = (event) => {
1008
+ const host = hostRef.current;
1009
+ if (host !== null && host.contains(event.target)) return;
1010
+ setOpen(false);
1011
+ };
1012
+ const onKeyDown = (event) => {
1013
+ if (event.key === 'Escape') setOpen(false);
1014
+ };
1015
+ document.addEventListener('mousedown', dismiss, true);
1016
+ document.addEventListener('keydown', onKeyDown, true);
1017
+ return () => {
1018
+ document.removeEventListener('mousedown', dismiss, true);
1019
+ document.removeEventListener('keydown', onKeyDown, true);
1020
+ };
1021
+ }, [open]);
1022
+
1023
+ // MenuSurface 是函数组件,不保证转发 ref,因此按条目数估算面板高度;
1024
+ // 列表本身有 max-height 和滚动,估算只影响贴底时的翻转位置。
1025
+ useEffect(() => {
1026
+ if (!open) return;
1027
+ const host = hostRef.current;
1028
+ if (host === null) return;
1029
+ const pinnedIds = new Set(effectivePinned(apps.ids));
1030
+ const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1031
+ const height = Math.min(380, Math.max(40, count * 34 + 8));
1032
+ setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
1033
+ }, [open, apps.status, apps.ids]);
1034
+
1035
+ // 会话行菜单的 pointerover/out 在 document 捕获阶段被「移出关闭」守卫截断,
1036
+ // React 的 onPointerEnter 收不到。把悬浮展开挂到 window 捕获阶段,抢在守卫前。
1037
+ useEffect(() => {
1038
+ const host = hostRef.current;
1039
+ if (host === null) return undefined;
1040
+ const inside = (node) => node instanceof Node && host.contains(node);
1041
+ const onOver = (event) => {
1042
+ if (inside(event.target)) openNow();
1043
+ };
1044
+ const onOut = (event) => {
1045
+ if (inside(event.target) && !inside(event.relatedTarget)) closeSoon();
1046
+ };
1047
+ window.addEventListener('pointerover', onOver, true);
1048
+ window.addEventListener('pointerout', onOut, true);
1049
+ return () => {
1050
+ window.removeEventListener('pointerover', onOver, true);
1051
+ window.removeEventListener('pointerout', onOut, true);
1052
+ };
1053
+ }, [open, apps.status, apps.ids]);
1054
+
1055
+ useEffect(() => () => window.clearTimeout(closeTimer.current), []);
1056
+
1057
+ const openNow = () => {
1058
+ window.clearTimeout(closeTimer.current);
1059
+ if (!open && hostRef.current !== null) {
1060
+ const pinnedIds = new Set(effectivePinned(apps.ids));
1061
+ const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
1062
+ const height = Math.min(380, Math.max(40, count * 34 + 8));
1063
+ setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
1064
+ }
1065
+ setOpen(true);
1066
+ ensureApps();
1067
+ };
1068
+ const closeSoon = () => {
1069
+ window.clearTimeout(closeTimer.current);
1070
+ closeTimer.current = window.setTimeout(() => setOpen(false), 160);
1071
+ };
1072
+ const pick = (appId) => {
1073
+ setOpen(false);
1074
+ closeParent?.();
1075
+ const cwd = getCwd();
1076
+ if (cwd === '') {
1077
+ toast(t('noPath'));
1078
+ return;
1079
+ }
1080
+ openApplication(appId, cwd).then((ok) => {
1081
+ if (!ok) toast(t('openFailed'));
1082
+ });
1083
+ };
1084
+
1085
+ const pinnedIds = new Set(effectivePinned(apps.ids));
1086
+ const enabledApps = apps.ids.filter((id) => childOn(id));
1087
+ const visibleApps = enabledApps.filter((id) => !pinnedIds.has(id));
1088
+ // 所有可用应用都置顶后,二级菜单没有内容,直接收起来。
1089
+ if (apps.status !== 'loading' && enabledApps.length > 0 && visibleApps.length === 0) {
1090
+ return null;
392
1091
  }
393
- items.push({ id: 'sep2', type: 'separator' });
394
- items.push({ id: 'delete', label: t('deleteWorkspace'), icon: jsx(IconTrashOutlineRegular, {}), danger: true });
1092
+ let panelBody;
1093
+ if (apps.status === 'error') {
1094
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, t('settings.appsError'));
1095
+ } else if (apps.ids.length === 0) {
1096
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, apps.status === 'loading' ? t('settings.appsLoading') : t('settings.appsUnavailable'));
1097
+ } else if (visibleApps.length === 0) {
1098
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, t('settings.appsAllOff'));
1099
+ } else {
1100
+ panelBody = visibleApps.map((id) => jsx(MenuItemButton, {
1101
+ key: id,
1102
+ onSelect: () => pick(id),
1103
+ icon: appIcon(id),
1104
+ children: appLabel(id, t),
1105
+ }));
1106
+ }
1107
+
1108
+ const body = h('div', {
1109
+ className: 'dsrm-submenu-host',
1110
+ ref: hostRef,
1111
+ onPointerOver: openNow,
1112
+ onPointerEnter: openNow,
1113
+ onPointerLeave: closeSoon,
1114
+ },
1115
+ h('button', {
1116
+ type: 'button',
1117
+ role: 'menuitem',
1118
+ className: 'dsrm-submenu-trigger',
1119
+ 'aria-haspopup': 'menu',
1120
+ 'aria-expanded': open,
1121
+ onPointerOver: openNow,
1122
+ onFocus: openNow,
1123
+ onClick: () => {
1124
+ if (open) setOpen(false);
1125
+ else openNow();
1126
+ },
1127
+ },
1128
+ jsx(IconRightUpOutlineRegular, { size: 14 }),
1129
+ h('span', { className: 'dsrm-submenu-label' }, t('openWith')),
1130
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
1131
+ ),
1132
+ open ? h(MenuSurface, {
1133
+ role: 'group',
1134
+ 'aria-label': t('openWith'),
1135
+ className: 'dsrm-submenu',
1136
+ style: {
1137
+ left: box?.left ?? 0,
1138
+ top: box?.top ?? 0,
1139
+ visibility: box === null ? 'hidden' : 'visible',
1140
+ },
1141
+ }, panelBody) : null,
1142
+ );
1143
+ if (!separatorBefore) return body;
1144
+ return h(Fragment, null,
1145
+ h('div', { className: 'dsrm-sep', role: 'separator' }),
1146
+ body,
1147
+ );
1148
+ }
1149
+
1150
+ /**
1151
+ * 会话菜单里的「打开方式」:只在右键打开的菜单里出现,
1152
+ * 且和 dsh-session-menu 的同名能力二选一。
1153
+ */
1154
+ function SessionOpenWithMenuItem({ sessionId, useMenuOpenState, t }) {
1155
+ const visible = useRightClickEntry();
1156
+ const items = useItems();
1157
+ const apps = useApps();
1158
+ const setMenuOpen = menuCloser(useMenuOpenState);
1159
+ if (!visible || items.openWith === false || takenOver('openWith')) return null;
1160
+ const pinnedCount = apps.status === 'ready'
1161
+ ? effectivePinned(apps.ids).filter((id) => childOn(id)).length
1162
+ : 0;
1163
+ return jsx(OpenWithSubmenu, {
1164
+ t,
1165
+ getCwd: () => sessionCwd(rootCtx, sessionId),
1166
+ closeParent: () => setMenuOpen?.(false),
1167
+ separatorBefore: apps.status === 'ready' && pinnedCount === 0,
1168
+ });
1169
+ }
1170
+
1171
+ /**
1172
+ * 置顶应用的一级菜单入口:默认自动把检测到的第一个编辑器(通常是 VS Code)
1173
+ * 放进一级菜单,用户也可以在设置里改。置顶项不再重复出现在「打开方式」里。
1174
+ */
1175
+ function SessionOpenWithPinnedMenuItem({ sessionId, useMenuOpenState, t }) {
1176
+ const visible = useRightClickEntry();
1177
+ const items = useItems();
1178
+ useChildren();
1179
+ const apps = useApps();
1180
+ usePinned();
1181
+ const setMenuOpen = menuCloser(useMenuOpenState);
1182
+ useEffect(() => {
1183
+ if (visible) ensureApps();
1184
+ }, [visible]);
1185
+ if (!visible || items.openWith === false || takenOver('openWith')) return null;
1186
+ const picked = effectivePinned(apps.ids).filter((id) => childOn(id));
1187
+ if (picked.length === 0) return null;
1188
+ const pick = (appId) => {
1189
+ setMenuOpen?.(false);
1190
+ const cwd = sessionCwd(rootCtx, sessionId);
1191
+ if (cwd === '') {
1192
+ toast(t('noPath'));
1193
+ return;
1194
+ }
1195
+ openApplication(appId, cwd).then((ok) => {
1196
+ if (!ok) toast(t('openFailed'));
1197
+ });
1198
+ };
1199
+ return h(Fragment, null, picked.map((id, index) => jsx(MenuItemButton, {
1200
+ key: id,
1201
+ separatorBefore: index === 0,
1202
+ onSelect: () => pick(id),
1203
+ icon: appIcon(id),
1204
+ children: t('openWithApp', { name: appLabel(id, t) }),
1205
+ })));
1206
+ }
1207
+
1208
+ /**
1209
+ * 会话条目「永久删除会话」:只在已归档会话的菜单里显示;和 dsh-session-menu
1210
+ * 的同名功能二选一。
1211
+ */
1212
+ function SessionDeleteMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1213
+ const visible = useRightClickEntry();
1214
+ const items = useItems();
1215
+ const archived = useArchivedIds();
1216
+ const setMenuOpen = menuCloser(useMenuOpenState);
1217
+ if (!visible || items.deleteSession === false || takenOver('deleteSession')) return null;
1218
+ if (!archived.has(sessionId)) return null;
1219
+ return jsx(MenuItemButton, {
1220
+ danger: true,
1221
+ separatorBefore: true,
1222
+ icon: jsx(IconTrashOutlineRegular, { size: 14 }),
1223
+ onSelect: () => {
1224
+ setMenuOpen?.(false);
1225
+ requestDelete({ mode: 'one', sessionId, displayTitle });
1226
+ },
1227
+ children: t('deleteSession'),
1228
+ });
1229
+ }
1230
+
1231
+ /**
1232
+ * 会话条目「删除所有已归档会话」:只在已归档会话的菜单里显示,
1233
+ * 和 dsh-session-menu 的同名功能二选一。displayTitle 用作确认文案。
1234
+ */
1235
+ function SessionDeleteArchivedMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1236
+ const visible = useRightClickEntry();
1237
+ const items = useItems();
1238
+ const archived = useArchivedIds();
1239
+ const setMenuOpen = menuCloser(useMenuOpenState);
1240
+ if (!visible || items.deleteArchived === false || takenOver('deleteArchived')) return null;
1241
+ if (!archived.has(sessionId)) return null;
1242
+ return jsx(MenuItemButton, {
1243
+ danger: true,
1244
+ separatorBefore: items.deleteSession === false || takenOver('deleteSession'),
1245
+ icon: jsx(IconTrashOutlineRegular, { size: 14 }),
1246
+ onSelect: () => {
1247
+ const ids = [...archived];
1248
+ setMenuOpen?.(false);
1249
+ if (ids.length === 0) return;
1250
+ requestDelete({ mode: 'archived', sessionIds: ids, displayTitle });
1251
+ },
1252
+ children: t('deleteArchived'),
1253
+ });
1254
+ }
1255
+
1256
+ /* ---------------------------------------------------------- 设置页 */
1257
+
1258
+ const SETTINGS_ROWS = [
1259
+ ['openWith', 'settings.openWith.label', 'settings.openWith.hint'],
1260
+ ['deleteSession', 'settings.deleteSession.label', 'settings.deleteSession.hint'],
1261
+ ['deleteArchived', 'settings.deleteArchived.label', 'settings.deleteArchived.hint'],
1262
+ ];
1263
+
1264
+ function SettingsSection({ t }) {
1265
+ const items = useItems();
1266
+ const children = useChildren();
1267
+ const apps = useApps();
1268
+ usePinned();
1269
+ const [expanded, setExpanded] = useState(false);
1270
+ const anyConflict = takenOver('openWith') || takenOver('deleteSession') || takenOver('deleteArchived');
1271
+ const locked = items.openWith === false;
1272
+ const pinnedIds = new Set(effectivePinned(apps.ids));
1273
+
1274
+ useEffect(() => {
1275
+ if (expanded) ensureApps();
1276
+ }, [expanded]);
395
1277
 
396
- const onSelect = (id) => {
1278
+ const toggleExpanded = () => {
1279
+ setExpanded(!expanded);
1280
+ if (!expanded) ensureApps();
1281
+ };
1282
+
1283
+ const childRows = expanded ? h('div', { className: 'dsrm-settings-children' },
1284
+ apps.status === 'error'
1285
+ ? h('div', { className: 'dsrm-settings-empty' }, t('settings.appsError'))
1286
+ : apps.ids.length === 0
1287
+ ? h('div', { className: 'dsrm-settings-empty' }, apps.status === 'loading' ? t('settings.appsLoading') : t('settings.appsUnavailable'))
1288
+ : apps.ids.map((id) => h('div', { className: 'dsrm-settings-child', key: id },
1289
+ h('span', { className: 'dsrm-settings-child-label' }, appLabel(id, t)),
1290
+ h('div', { className: 'dsrm-settings-child-actions' },
1291
+ h('button', {
1292
+ type: 'button',
1293
+ className: pinnedIds.has(id) ? 'dsrm-pin dsrm-pin-on' : 'dsrm-pin',
1294
+ 'aria-pressed': pinnedIds.has(id),
1295
+ disabled: locked || !childOn(id),
1296
+ title: t('settings.pinTitle'),
1297
+ onClick: () => togglePinned(id, apps.ids),
1298
+ }, pinnedIds.has(id) ? t('settings.pinned') : t('settings.pin')),
1299
+ jsx(Switch, {
1300
+ checked: childOn(id),
1301
+ disabled: locked,
1302
+ label: appLabel(id, t),
1303
+ title: locked ? t('settings.childLocked', { name: t('settings.openWith.label') }) : undefined,
1304
+ onChange: (next) => setChild(id, next),
1305
+ }),
1306
+ ),
1307
+ )),
1308
+ ) : null;
1309
+
1310
+ // 应用开关必须紧跟「打开方式」这一行,而不是落到整个列表末尾。
1311
+ const rows = SETTINGS_ROWS.flatMap(([key, labelKey, hintKey]) => {
1312
+ const row = h('div', { className: 'dsrm-settings-row', key },
1313
+ h('div', { className: 'dsrm-settings-copy' },
1314
+ h('div', { className: 'dsrm-settings-label' }, t(labelKey)),
1315
+ h('div', { className: 'dsrm-settings-hint' }, takenOver(key) ? t('settings.conflict') : t(hintKey)),
1316
+ ),
1317
+ h('div', { className: 'dsrm-settings-controls' },
1318
+ jsx(Switch, {
1319
+ checked: items[key] !== false,
1320
+ disabled: takenOver(key),
1321
+ label: t(labelKey),
1322
+ onChange: (next) => setItem(key, next),
1323
+ }),
1324
+ key === 'openWith'
1325
+ ? h('button', {
1326
+ type: 'button',
1327
+ className: 'dsrm-expander',
1328
+ 'aria-expanded': expanded,
1329
+ disabled: takenOver(key),
1330
+ title: expanded ? t('settings.collapse') : t('settings.expand'),
1331
+ onClick: toggleExpanded,
1332
+ }, jsx(IconTriangleRightFillRegular, {
1333
+ size: 10,
1334
+ className: expanded ? 'dsrm-expander-icon dsrm-expander-open' : 'dsrm-expander-icon',
1335
+ }))
1336
+ : null,
1337
+ ),
1338
+ );
1339
+ return key === 'openWith' && childRows ? [row, childRows] : [row];
1340
+ });
1341
+
1342
+ return h('div', { className: 'dsrm-settings' },
1343
+ h('h2', { className: 'dsrm-settings-title' }, t('settings.title')),
1344
+ anyConflict ? h('div', { className: 'dsrm-settings-note' }, t('settings.conflictNote')) : null,
1345
+ h('div', { className: 'dsrm-settings-list' }, rows),
1346
+ );
1347
+ }
1348
+
1349
+ /* ---------------------------------------------------------- 菜单 */
1350
+
1351
+ /**
1352
+ * 工作区行右键菜单。官方 Menu 的 items 只认单层条目,不支持子菜单,
1353
+ * 所以这里自绘 panel;普通行仍用 MenuItemButton,分隔线与菜单外观和官方一致。
1354
+ */
1355
+ function WorkspaceRowMenu({ x, y, props, onClose }) {
1356
+ const t = localeOf(rootCtx);
1357
+ const items = useItems();
1358
+ const apps = useApps();
1359
+ usePinned();
1360
+ useChildren();
1361
+ const hostRef = useRef(null);
1362
+ const [pos, setPos] = useState(null);
1363
+
1364
+ const openWithOn = items.openWith !== false;
1365
+
1366
+ useEffect(() => {
1367
+ if (openWithOn) ensureApps();
1368
+ }, [openWithOn]);
1369
+
1370
+ useEffect(() => {
1371
+ const host = hostRef.current;
1372
+ const dismiss = (event) => {
1373
+ if (host !== null && host.contains(event.target)) return;
1374
+ onClose();
1375
+ };
1376
+ const onKeyDown = (event) => {
1377
+ if (event.key === 'Escape') onClose();
1378
+ };
1379
+ document.addEventListener('mousedown', dismiss, true);
1380
+ document.addEventListener('keydown', onKeyDown, true);
1381
+ return () => {
1382
+ document.removeEventListener('mousedown', dismiss, true);
1383
+ document.removeEventListener('keydown', onKeyDown, true);
1384
+ };
1385
+ }, [onClose]);
1386
+
1387
+ // 置顶应用是异步取到的,行数会变;渲染后量一次面板高度再夹进视口。
1388
+ useLayoutEffect(() => {
1389
+ const host = hostRef.current;
1390
+ if (host === null) return;
1391
+ const rect = host.getBoundingClientRect();
1392
+ const margin = 8;
1393
+ const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
1394
+ const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
1395
+ setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
1396
+ }, [x, y, apps.status]);
1397
+
1398
+ const group = props.group ?? {};
1399
+ const workspaceId = typeof group.workspaceId === 'string' ? group.workspaceId : '';
1400
+ const cwd = workspaceCwd(rootCtx, group);
1401
+ const tabs = serviceOf(rootCtx, 'projectTabs') ?? globalThis.__DSH_PROJECT_TABS__ ?? null;
1402
+ const canOpenInTab = tabs !== null && typeof tabs.openWorkspaceInTab === 'function' && workspaceId !== '';
1403
+
1404
+ const openApp = (appId) => {
1405
+ if (cwd === '') {
1406
+ toast(t('noPath'));
1407
+ return;
1408
+ }
1409
+ openApplication(appId, cwd).then((ok) => {
1410
+ if (!ok) toast(t('openFailed'));
1411
+ });
1412
+ };
1413
+
1414
+ const run = (id) => {
397
1415
  if (id === 'new-session') {
398
1416
  try {
399
1417
  props.onCreate?.();
@@ -431,12 +1449,125 @@ window.__ModuleLoader__.load({
431
1449
  return;
432
1450
  }
433
1451
  copyText(cwd, t);
434
- return;
435
1452
  }
436
- openPath(id, cwd, t);
437
1453
  };
438
1454
 
439
- showMenu(items, onSelect, x, y);
1455
+ const rows = [];
1456
+ if (typeof props.onCreate === 'function') {
1457
+ rows.push(jsx(MenuItemButton, {
1458
+ key: 'new-session',
1459
+ onSelect: () => {
1460
+ onClose();
1461
+ run('new-session');
1462
+ },
1463
+ icon: jsx(IconNewChatOutlineRegular, {}),
1464
+ children: t('newSession'),
1465
+ }));
1466
+ }
1467
+ rows.push(jsx(MenuItemButton, {
1468
+ key: 'rename',
1469
+ onSelect: () => {
1470
+ onClose();
1471
+ run('rename');
1472
+ },
1473
+ icon: jsx(IconEditOutlineRegular, {}),
1474
+ children: t('rename'),
1475
+ }));
1476
+
1477
+ if (openWithOn) {
1478
+ const pinned = effectivePinned(apps.ids).filter((id) => childOn(id));
1479
+ pinned.forEach((id, index) => rows.push(jsx(MenuItemButton, {
1480
+ key: `pinned-${id}`,
1481
+ separatorBefore: index === 0,
1482
+ onSelect: () => {
1483
+ onClose();
1484
+ openApp(id);
1485
+ },
1486
+ icon: appIcon(id),
1487
+ children: t('openWithApp', { name: appLabel(id, t) }),
1488
+ })));
1489
+ rows.push(jsx(OpenWithSubmenu, {
1490
+ key: 'open-with',
1491
+ t,
1492
+ getCwd: () => cwd,
1493
+ closeParent: onClose,
1494
+ separatorBefore: pinned.length === 0,
1495
+ }));
1496
+ }
1497
+
1498
+ rows.push(jsx(MenuItemButton, {
1499
+ key: 'copy-path',
1500
+ separatorBefore: !openWithOn,
1501
+ onSelect: () => {
1502
+ onClose();
1503
+ run('copy-path');
1504
+ },
1505
+ icon: jsx(IconCopyOutlineRegular, { size: 14 }),
1506
+ children: t('copyPath'),
1507
+ }));
1508
+ if (canOpenInTab) {
1509
+ rows.push(jsx(MenuItemButton, {
1510
+ key: 'tab',
1511
+ onSelect: () => {
1512
+ onClose();
1513
+ run('tab');
1514
+ },
1515
+ icon: jsx(IconProjectAddOutlineRegular, { size: 14 }),
1516
+ children: t('openInTab'),
1517
+ }));
1518
+ }
1519
+ rows.push(jsx(MenuItemButton, {
1520
+ key: 'delete',
1521
+ danger: true,
1522
+ separatorBefore: true,
1523
+ onSelect: () => {
1524
+ onClose();
1525
+ run('delete');
1526
+ },
1527
+ icon: jsx(IconTrashOutlineRegular, {}),
1528
+ children: t('deleteWorkspace'),
1529
+ }));
1530
+
1531
+ return h('div', {
1532
+ className: 'dsrm-menu',
1533
+ ref: hostRef,
1534
+ style: {
1535
+ left: pos?.left ?? x,
1536
+ top: pos?.top ?? y,
1537
+ visibility: pos === null ? 'hidden' : 'visible',
1538
+ },
1539
+ }, h(MenuSurface, { role: 'group', className: 'dsrm-menu-surface' }, rows));
1540
+ }
1541
+
1542
+ /** 在 (x, y) 处挂出工作区菜单。 */
1543
+ function showWorkspaceMenu(props, x, y) {
1544
+ closeMenu();
1545
+ menuHost = document.createElement('div');
1546
+ menuHost.setAttribute(MENU_ATTR, '1');
1547
+ menuHost.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647';
1548
+ document.body.appendChild(menuHost);
1549
+ menuRoot = createRoot(menuHost);
1550
+ menuRoot.render(jsx(WorkspaceRowMenu, {
1551
+ x,
1552
+ y,
1553
+ props,
1554
+ onClose: () => closeMenu(),
1555
+ }));
1556
+ }
1557
+
1558
+ function localeOf(ctx) {
1559
+ try {
1560
+ if (typeof ctx?.locale?.bind === 'function') return ctx.locale.bind(NS);
1561
+ } catch (error) {
1562
+ console.warn('[dsh-sidebar-row-menu] locale bind failed:', error);
1563
+ }
1564
+ return (key) => key;
1565
+ }
1566
+
1567
+ /** 工作区行右键菜单(没有 slot 可复用,只能自己组)。 */
1568
+ function openWorkspaceMenu(props, x, y) {
1569
+ if (rootCtx === null) return;
1570
+ showWorkspaceMenu(props, x, y);
440
1571
  }
441
1572
 
442
1573
  /* ------------------------------------------------ 会话菜单的插件条目 */
@@ -453,75 +1584,62 @@ window.__ModuleLoader__.load({
453
1584
  }
454
1585
 
455
1586
  /**
456
- * 会话菜单条目:在 VS Code 中打开。
457
- * 条目只拿到 { sessionId, displayTitle } 与 slot 注入的 useMenuOpenState / t,
1587
+ * 会话菜单条目:
1588
+ * - 置顶应用 + 「打开方式」子菜单(官方 open-in-app 路由)
1589
+ * - 「永久删除会话」「删除所有已归档会话」(仅已归档会话显示)
458
1590
  * 目录从客户端服务里现取 —— 这样不依赖行组件的内部闭包。
459
1591
  */
460
- function SessionVscodeMenuItem({ sessionId, useMenuOpenState, t }) {
461
- const visible = useRightClickEntry();
462
- const [, setMenuOpen] = useMenuOpenState();
463
- if (!visible) return null;
464
- return jsx(MenuItemButton, {
465
- separatorBefore: true,
466
- icon: jsx(IconCodeOutlineRegular, { size: 14 }),
467
- onSelect: () => {
468
- setMenuOpen(false);
469
- openPath('vscode', sessionCwd(rootCtx, sessionId), t);
470
- },
471
- children: t('vscode'),
1592
+ function registerSessionMenu(ctx) {
1593
+ return ctx.slots.inject(SESSION_MENU_SLOT, function* () {
1594
+ yield ctx.slots.register({
1595
+ name: SESSION_MENU_SLOT,
1596
+ id: 'row-menu-open-with-pinned',
1597
+ order: 495,
1598
+ locale: NS,
1599
+ }, SessionOpenWithPinnedMenuItem);
1600
+ yield ctx.slots.register({
1601
+ name: SESSION_MENU_SLOT,
1602
+ id: 'row-menu-open-with',
1603
+ order: 500,
1604
+ locale: NS,
1605
+ }, SessionOpenWithMenuItem);
1606
+ yield ctx.slots.register({
1607
+ name: SESSION_MENU_SLOT,
1608
+ id: 'row-menu-delete-session',
1609
+ order: 510,
1610
+ locale: NS,
1611
+ }, SessionDeleteMenuItem);
1612
+ yield ctx.slots.register({
1613
+ name: SESSION_MENU_SLOT,
1614
+ id: 'row-menu-delete-archived',
1615
+ order: 520,
1616
+ locale: NS,
1617
+ }, SessionDeleteArchivedMenuItem);
472
1618
  });
473
1619
  }
474
1620
 
475
- /** 会话菜单条目:在资源管理器中显示。 */
476
- function SessionExplorerMenuItem({ sessionId, useMenuOpenState, t }) {
477
- const visible = useRightClickEntry();
478
- const [, setMenuOpen] = useMenuOpenState();
479
- if (!visible) return null;
480
- return jsx(MenuItemButton, {
481
- icon: jsx(IconFolderOpenRegular, { size: 14 }),
482
- onSelect: () => {
483
- setMenuOpen(false);
484
- openPath('explorer', sessionCwd(rootCtx, sessionId), t);
485
- },
486
- children: t('explorer'),
1621
+ /** 删除确认框挂到 shell.overlay,避免菜单关闭时被一起卸载。 */
1622
+ function registerDeleteDialog(ctx) {
1623
+ return ctx.slots.inject('shell.overlay', function* () {
1624
+ yield ctx.slots.register({
1625
+ name: 'shell.overlay',
1626
+ id: 'sidebar-row-menu-delete-dialog',
1627
+ order: 60,
1628
+ locale: NS,
1629
+ }, DeleteDialog);
487
1630
  });
488
1631
  }
489
1632
 
490
- /** 会话菜单条目:复制会话链接。 */
491
- function SessionCopyLinkMenuItem({ sessionId, useMenuOpenState, t }) {
492
- const visible = useRightClickEntry();
493
- const [, setMenuOpen] = useMenuOpenState();
494
- if (!visible) return null;
495
- return jsx(MenuItemButton, {
496
- icon: jsx(IconLinkOutlineRegular, { size: 14 }),
497
- onSelect: () => {
498
- setMenuOpen(false);
499
- copyText(deepLink(sessionId), t);
500
- },
501
- children: t('copyLink'),
502
- });
503
- }
504
-
505
- /**
506
- * 把本插件自己的会话动作注册进内置「…」菜单的 slot。
507
- * 排序放在内置条目之后(pin 100 / rename 200 / fork 300 / archive 400 /
508
- * session-id 450),分隔线由第一条的 separatorBefore 提供。
509
- */
510
- function registerSessionMenu(ctx) {
511
- const entries = [
512
- { id: 'row-menu-vscode', order: 500, Component: SessionVscodeMenuItem },
513
- { id: 'row-menu-explorer', order: 510, Component: SessionExplorerMenuItem },
514
- { id: 'row-menu-copy-link', order: 520, Component: SessionCopyLinkMenuItem },
515
- ];
516
- return ctx.slots.inject(SESSION_MENU_SLOT, function* () {
517
- for (const entry of entries) {
518
- yield ctx.slots.register({
519
- name: SESSION_MENU_SLOT,
520
- id: entry.id,
521
- order: entry.order,
522
- locale: NS,
523
- }, entry.Component);
524
- }
1633
+ /** 设置页:所有新增项都能在这里关掉。 */
1634
+ function registerSettings(ctx) {
1635
+ return ctx.slots.inject('settings.section', function* () {
1636
+ yield ctx.slots.register({
1637
+ name: 'settings.section',
1638
+ id: 'sidebar-row-menu',
1639
+ order: 32,
1640
+ label: () => ctx.locale.bind(NS)('settings.title'),
1641
+ locale: NS,
1642
+ }, SettingsSection);
525
1643
  });
526
1644
  }
527
1645
 
@@ -704,6 +1822,57 @@ window.__ModuleLoader__.load({
704
1822
  if (trigger !== null) trigger.click();
705
1823
  }
706
1824
 
1825
+ /* -------------------------------------------------- 样式(子菜单 / 设置页) */
1826
+
1827
+ const STYLE = `
1828
+ .dsrm-submenu-host{position:relative;display:flex;align-items:center;width:100%}
1829
+ .dsrm-submenu-trigger{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:0;border-radius:var(--dsw-radius-md);background:none;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;text-align:left;cursor:pointer}
1830
+ .dsrm-submenu-trigger:hover:not(:disabled),.dsrm-submenu-trigger:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
1831
+ .dsrm-submenu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1832
+ .dsrm-submenu-chevron{color:var(--dsw-alias-label-caption)}
1833
+ .dsrm-submenu{position:fixed;z-index:1101;min-width:180px;max-width:300px;max-height:min(380px,64vh);overflow:auto;padding:4px}
1834
+ .dsrm-submenu-empty{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
1835
+ .dsrm-menu{position:fixed;z-index:2147483647}
1836
+ .dsrm-menu-surface{min-width:210px;padding:4px}
1837
+ .dsrm-sep{height:.5px;margin:3px 2px;background:var(--dsw-alias-border-l2)}
1838
+ .dsrm-app-icon{width:14px;height:14px;border-radius:3px;display:block;object-fit:contain}
1839
+ .dsrm-settings{max-width:820px;margin:0 auto;padding:2px 0 36px}
1840
+ .dsrm-settings-title{margin:0;font-size:16px;line-height:24px;font-weight:600}
1841
+ .dsrm-settings-note{margin-top:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
1842
+ .dsrm-settings-list{margin-top:10px;display:flex;flex-direction:column}
1843
+ .dsrm-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:14px 0;border-top:.5px solid var(--dsw-alias-border-l2)}
1844
+ .dsrm-settings-row:first-child{border-top:0}
1845
+ .dsrm-settings-copy{display:flex;flex-direction:column;gap:4px;min-width:0}
1846
+ .dsrm-settings-label{font-size:14px;line-height:20px}
1847
+ .dsrm-settings-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
1848
+ .dsrm-settings-controls{display:flex;align-items:center;gap:10px}
1849
+ .dsrm-settings-children{margin-left:12px;padding:0 0 12px 18px;border-left:1px solid var(--dsw-alias-border-l2)}
1850
+ .dsrm-settings-child{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:6px 14px 6px 0}
1851
+ .dsrm-settings-child-label{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1852
+ .dsrm-settings-child-actions{display:flex;align-items:center;gap:10px;flex:none}
1853
+ .dsrm-pin{border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;padding:1px 8px;cursor:pointer}
1854
+ .dsrm-pin:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1855
+ .dsrm-pin:disabled{opacity:.45;cursor:default}
1856
+ .dsrm-pin-on{border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-label-primary)}
1857
+ .dsrm-settings-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
1858
+ .dsrm-expander{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-alias-label-caption);cursor:pointer}
1859
+ .dsrm-expander:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
1860
+ .dsrm-expander-icon{transition:transform .12s ease}
1861
+ .dsrm-expander-open{transform:rotate(90deg)}
1862
+ .dsrm-danger-label{color:var(--dsw-alias-label-danger,#d33)}
1863
+ .dsrm-status-text{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
1864
+ .dsrm-error-text{margin-top:8px;color:var(--dsw-alias-label-danger,#d33);font-size:12px;line-height:18px;word-break:break-word}
1865
+ `;
1866
+
1867
+ function installStyles() {
1868
+ if (document.head === null || document.head === undefined) return () => {};
1869
+ const style = document.createElement('style');
1870
+ style.setAttribute('data-dsh-sidebar-row-menu-style', '');
1871
+ style.textContent = STYLE;
1872
+ document.head.appendChild(style);
1873
+ return () => style.remove();
1874
+ }
1875
+
707
1876
  /* -------------------------------------------------------------- 安装 */
708
1877
 
709
1878
  /** 全局 contextmenu 拦截:只有命中工作区 / 会话行才接管。 */
@@ -747,6 +1916,7 @@ window.__ModuleLoader__.load({
747
1916
  return () => {
748
1917
  window.removeEventListener('contextmenu', onContextMenu, true);
749
1918
  closeMenu();
1919
+ closeToast();
750
1920
  };
751
1921
  }
752
1922
 
@@ -764,6 +1934,11 @@ window.__ModuleLoader__.load({
764
1934
  // `slots.inject` 自带 ctx.effect 生命周期,不要再包一层 effect:
765
1935
  // 它会在 slot 声明出现时注册、插件卸载时自动撤销。
766
1936
  registerSessionMenu(ctx);
1937
+ registerDeleteDialog(ctx);
1938
+ registerSettings(ctx);
1939
+ }
1940
+ if (typeof ctx.effect === 'function') {
1941
+ ctx.effect(() => installStyles(), 'sidebar-row-menu: styles');
767
1942
  }
768
1943
  const teardown = install();
769
1944
  if (ctx !== null && typeof ctx.effect === 'function') {