dsh-sidebar-row-menu 1.3.5 → 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/CHANGELOG.md +69 -0
- package/NOTICE.en.md +2 -2
- package/NOTICE.md +2 -1
- package/README.en.md +88 -29
- package/README.md +72 -23
- package/lib/client.js +1294 -117
- package/lib/host/route.js +87 -6
- package/lib/host/session-delete.js +226 -0
- package/lib/index.js +23 -18
- package/package.json +4 -3
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 {
|
|
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,37 @@ 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
|
+
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
|
+
};
|
|
75
117
|
|
|
76
118
|
/* ------------------------------------------------------------ 文案 */
|
|
77
119
|
|
|
@@ -88,6 +130,44 @@ window.__ModuleLoader__.load({
|
|
|
88
130
|
noPath: '没有可用的目录路径',
|
|
89
131
|
openFailed: '打开失败:宿主没有找到对应程序',
|
|
90
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} 个会话',
|
|
91
171
|
};
|
|
92
172
|
const en = {
|
|
93
173
|
newSession: 'New session',
|
|
@@ -102,7 +182,87 @@ window.__ModuleLoader__.load({
|
|
|
102
182
|
noPath: 'No usable directory path',
|
|
103
183
|
openFailed: 'Open failed: no handler found on the host',
|
|
104
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'],
|
|
105
261
|
};
|
|
262
|
+
for (const [id, names] of Object.entries(APP_NAMES)) {
|
|
263
|
+
zh[`app.${id}`] = names[0];
|
|
264
|
+
en[`app.${id}`] = names[1];
|
|
265
|
+
}
|
|
106
266
|
|
|
107
267
|
/* -------------------------------------------------- React fiber 取值 */
|
|
108
268
|
|
|
@@ -324,89 +484,934 @@ window.__ModuleLoader__.load({
|
|
|
324
484
|
return typeof item?.path === 'string' ? item.path : '';
|
|
325
485
|
}
|
|
326
486
|
|
|
327
|
-
/*
|
|
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
|
+
}
|
|
328
647
|
|
|
329
648
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* 视口夹取、键盘导航和「点外部 / Esc 关闭」。
|
|
649
|
+
* 实际置顶的应用:用户没配置过时自动挑选 —— 有已知编辑器就置顶第一个
|
|
650
|
+
* 编辑器(通常是 VS Code),否则置顶官方列表里的第一个应用。
|
|
333
651
|
*/
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
() => (
|
|
337
|
-
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
anchor: null,
|
|
342
|
-
items,
|
|
343
|
-
portal: true,
|
|
344
|
-
autoFocus: true,
|
|
345
|
-
align: 'start',
|
|
346
|
-
side: 'bottom',
|
|
347
|
-
getAnchorRect,
|
|
348
|
-
onSelect,
|
|
349
|
-
onClose,
|
|
350
|
-
});
|
|
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);
|
|
351
659
|
}
|
|
352
660
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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';
|
|
368
690
|
},
|
|
369
|
-
|
|
370
|
-
}));
|
|
691
|
+
});
|
|
371
692
|
}
|
|
372
693
|
|
|
373
|
-
|
|
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;
|
|
374
774
|
try {
|
|
375
|
-
|
|
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
|
+
}
|
|
376
782
|
} catch (error) {
|
|
377
|
-
console.warn('[dsh-sidebar-row-menu]
|
|
783
|
+
console.warn('[dsh-sidebar-row-menu] slot lookup failed:', error);
|
|
378
784
|
}
|
|
379
|
-
return
|
|
785
|
+
return ids;
|
|
380
786
|
}
|
|
381
787
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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);
|
|
788
|
+
function takenOver(key) {
|
|
789
|
+
const id = FOREIGN_ITEMS[key];
|
|
790
|
+
return typeof id === 'string' && foreignItemIds().has(id);
|
|
791
|
+
}
|
|
390
792
|
|
|
391
|
-
|
|
392
|
-
const canOpenInTab = tabs !== null && typeof tabs.openWorkspaceInTab === 'function' && workspaceId !== '';
|
|
793
|
+
/* ------------------------------------------------------ 已归档会话集合 */
|
|
393
794
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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;
|
|
397
831
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
}
|
|
405
863
|
}
|
|
406
|
-
|
|
407
|
-
|
|
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;
|
|
1091
|
+
}
|
|
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]);
|
|
1277
|
+
|
|
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]);
|
|
408
1397
|
|
|
409
|
-
const
|
|
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) => {
|
|
410
1415
|
if (id === 'new-session') {
|
|
411
1416
|
try {
|
|
412
1417
|
props.onCreate?.();
|
|
@@ -444,12 +1449,125 @@ window.__ModuleLoader__.load({
|
|
|
444
1449
|
return;
|
|
445
1450
|
}
|
|
446
1451
|
copyText(cwd, t);
|
|
447
|
-
return;
|
|
448
1452
|
}
|
|
449
|
-
openPath(id, cwd, t);
|
|
450
1453
|
};
|
|
451
1454
|
|
|
452
|
-
|
|
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);
|
|
453
1571
|
}
|
|
454
1572
|
|
|
455
1573
|
/* ------------------------------------------------ 会话菜单的插件条目 */
|
|
@@ -466,59 +1584,62 @@ window.__ModuleLoader__.load({
|
|
|
466
1584
|
}
|
|
467
1585
|
|
|
468
1586
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
1587
|
+
* 会话菜单条目:
|
|
1588
|
+
* - 置顶应用 + 「打开方式」子菜单(官方 open-in-app 路由)
|
|
1589
|
+
* - 「永久删除会话」「删除所有已归档会话」(仅已归档会话显示)
|
|
471
1590
|
* 目录从客户端服务里现取 —— 这样不依赖行组件的内部闭包。
|
|
472
1591
|
*/
|
|
473
|
-
function
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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);
|
|
485
1618
|
});
|
|
486
1619
|
}
|
|
487
1620
|
|
|
488
|
-
/**
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
openPath('explorer', sessionCwd(rootCtx, sessionId), t);
|
|
498
|
-
},
|
|
499
|
-
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);
|
|
500
1630
|
});
|
|
501
1631
|
}
|
|
502
1632
|
|
|
503
|
-
/**
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
}
|
|
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);
|
|
522
1643
|
});
|
|
523
1644
|
}
|
|
524
1645
|
|
|
@@ -701,6 +1822,57 @@ window.__ModuleLoader__.load({
|
|
|
701
1822
|
if (trigger !== null) trigger.click();
|
|
702
1823
|
}
|
|
703
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
|
+
|
|
704
1876
|
/* -------------------------------------------------------------- 安装 */
|
|
705
1877
|
|
|
706
1878
|
/** 全局 contextmenu 拦截:只有命中工作区 / 会话行才接管。 */
|
|
@@ -762,6 +1934,11 @@ window.__ModuleLoader__.load({
|
|
|
762
1934
|
// `slots.inject` 自带 ctx.effect 生命周期,不要再包一层 effect:
|
|
763
1935
|
// 它会在 slot 声明出现时注册、插件卸载时自动撤销。
|
|
764
1936
|
registerSessionMenu(ctx);
|
|
1937
|
+
registerDeleteDialog(ctx);
|
|
1938
|
+
registerSettings(ctx);
|
|
1939
|
+
}
|
|
1940
|
+
if (typeof ctx.effect === 'function') {
|
|
1941
|
+
ctx.effect(() => installStyles(), 'sidebar-row-menu: styles');
|
|
765
1942
|
}
|
|
766
1943
|
const teardown = install();
|
|
767
1944
|
if (ctx !== null && typeof ctx.effect === 'function') {
|