dsh-sidebar-row-menu 1.3.5 → 1.4.1

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