dsh-sidebar-row-menu 1.8.0 → 1.9.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
@@ -144,6 +144,26 @@ window.__ModuleLoader__.load({
144
144
  sessionDeleteArchived: false,
145
145
  };
146
146
  const ELLIPSIS_KEYS = Object.keys(ELLIPSIS_DEFAULTS);
147
+ /**
148
+ * 会话正文空白处右键菜单:把右上角工具条那排按钮按视觉顺序列成一份纵向菜单。
149
+ * 顺序必须量 getBoundingClientRect:dsh-sidebar-organizer 用 CSS `order`
150
+ * 排序,DOM 顺序不代表屏幕顺序。
151
+ */
152
+ const BLANK_MENU_KEY = 'dsh.sidebar-row-menu.blank-menu.v1';
153
+ const BLANK_MENU_DEFAULTS = {
154
+ enabled: true,
155
+ keepMoreLast: true,
156
+ showCostMeter: false,
157
+ };
158
+ const BLANK_MENU_KEYS = Object.keys(BLANK_MENU_DEFAULTS);
159
+ /** 「更多操作」类入口的标签;用于按用户要求把它固定在菜单末尾。 */
160
+ const MORE_LABEL_RE = /^(more|more tools|more actions|more options|更多|更多工具|更多操作|更多选项)$/i;
161
+ /**
162
+ * dsh-sidebar-organizer 记住的「更多操作」弹层行序(按行文字)。它的重排是
163
+ * 给宿主菜单行写 CSS `order`,DOM/数据顺序都不变;本插件的二级菜单要跟它
164
+ * 联动,只能读这份顺序再排序。
165
+ */
166
+ const ORGANIZER_MENU_ORDER_KEY = 'dsh.sidebar-organizer.host-menu-order.v1';
147
167
  /**
148
168
  * dsh-session-menu 注册的同一能力条目 id。检测到对方存在时,本插件对应项
149
169
  * 自动隐藏,避免同一份菜单里出现两条一模一样的动作。
@@ -222,6 +242,14 @@ window.__ModuleLoader__.load({
222
242
  'settings.ellipsis.sessionDelete.hint': '在「…」菜单里永久删除这一个会话及其日志。',
223
243
  'settings.ellipsis.sessionDeleteArchived.label': '删除所有已归档会话',
224
244
  'settings.ellipsis.sessionDeleteArchived.hint': '在已归档会话的「…」菜单里一次清理全部归档会话。',
245
+ 'settings.blankMenuTitle': '会话正文右键',
246
+ 'settings.blankMenuNote': '在会话正文里右键(空白处、思考过程、回复都可以),会按顶部工具条的视觉顺序列出那排按钮;装了 dsh-sidebar-organizer 时跟随它的排序。分组之间自动加分隔线,「打开方式」和「更多操作」按工作区 / 会话菜单的规则收纳。输入框、工具卡片和工具条本身仍走各自的原生菜单。',
247
+ 'settings.blankMenu.enabled.label': '启用正文右键菜单',
248
+ 'settings.blankMenu.enabled.hint': '在会话正文的空白处、思考过程或回复上右键,弹出与右上角工具条一致的纵向菜单;取不到按钮时不接管原生菜单。',
249
+ 'settings.blankMenu.keepMoreLast.label': '「更多」固定在最右',
250
+ 'settings.blankMenu.keepMoreLast.hint': '把「更多操作」一类的入口固定在菜单最后一项,不受工具条当前顺序影响。',
251
+ 'settings.blankMenu.showCostMeter.label': '显示费用明细',
252
+ 'settings.blankMenu.showCostMeter.hint': '把「本会话费用明细」这类统计入口也列进菜单;默认隐藏,需要时再打开。',
225
253
  'delete.title': '永久删除会话?',
226
254
  'delete.description': '会话「{name}」的日志将从磁盘永久删除,此操作无法撤销。',
227
255
  'delete.untitled': '未命名会话',
@@ -312,6 +340,14 @@ window.__ModuleLoader__.load({
312
340
  'settings.ellipsis.sessionDelete.hint': 'Permanently delete this session and its log from the "…" menu.',
313
341
  'settings.ellipsis.sessionDeleteArchived.label': 'Delete all archived sessions',
314
342
  'settings.ellipsis.sessionDeleteArchived.hint': 'Clean up every archived session from an archived row\'s "…" menu.',
343
+ 'settings.blankMenuTitle': 'Right-click in the conversation body',
344
+ 'settings.blankMenuNote': 'Right-click anywhere in the conversation body (blank space, thinking blocks or replies) to list the header toolbar buttons in their visual order. With dsh-sidebar-organizer installed the order follows its layout. Sections are separated by faint rules and "Open with" / "More actions" are collapsed like the workspace / session menus. Inputs, tool cards and the toolbar keep their own native menus.',
345
+ 'settings.blankMenu.enabled.label': 'Enable conversation-body context menu',
346
+ 'settings.blankMenu.enabled.hint': 'Right-click blank space, thinking blocks or replies in the conversation body to open a vertical menu mirroring the top-right toolbar. When no button can be found the native menu is left alone.',
347
+ 'settings.blankMenu.keepMoreLast.label': 'Pin "More" to the far right',
348
+ 'settings.blankMenu.keepMoreLast.hint': 'Always put the "More" style entry last, regardless of its current position in the toolbar.',
349
+ 'settings.blankMenu.showCostMeter.label': 'Show cost details',
350
+ 'settings.blankMenu.showCostMeter.hint': 'Also list session cost/usage entries such as "Session cost". Hidden by default; turn it on when you want them.',
315
351
  'delete.title': 'Delete this session permanently?',
316
352
  'delete.description': 'The log of "{name}" will be removed from disk. This cannot be undone.',
317
353
  'delete.untitled': 'Untitled session',
@@ -733,6 +769,70 @@ window.__ModuleLoader__.load({
733
769
  || ellipsisSettings.deleteSessions === true;
734
770
  }
735
771
 
772
+ /* -------------------------------------- 会话空白处右键菜单(localStorage) */
773
+
774
+ function readStoredBlankMenu() {
775
+ const defaults = { ...BLANK_MENU_DEFAULTS };
776
+ try {
777
+ const raw = globalThis.localStorage?.getItem(BLANK_MENU_KEY);
778
+ if (typeof raw !== 'string' || raw === '') return defaults;
779
+ const parsed = JSON.parse(raw);
780
+ if (parsed === null || typeof parsed !== 'object') return defaults;
781
+ for (const key of BLANK_MENU_KEYS) {
782
+ if (typeof parsed[key] === 'boolean') defaults[key] = parsed[key];
783
+ }
784
+ } catch {
785
+ /* 坏数据回退默认,不阻断右键 */
786
+ }
787
+ return defaults;
788
+ }
789
+
790
+ let blankMenuSettings = readStoredBlankMenu();
791
+ const blankMenuListeners = new Set();
792
+
793
+ function publishBlankMenu() {
794
+ for (const listener of blankMenuListeners) {
795
+ try {
796
+ listener();
797
+ } catch {
798
+ /* ignore */
799
+ }
800
+ }
801
+ }
802
+
803
+ function subscribeBlankMenu(listener) {
804
+ blankMenuListeners.add(listener);
805
+ return () => {
806
+ blankMenuListeners.delete(listener);
807
+ };
808
+ }
809
+
810
+ function blankMenuSnapshot() {
811
+ return blankMenuSettings;
812
+ }
813
+
814
+ function useBlankMenu() {
815
+ return useSyncExternalStore(subscribeBlankMenu, blankMenuSnapshot, blankMenuSnapshot);
816
+ }
817
+
818
+ function setBlankMenu(key, enabled) {
819
+ if (!BLANK_MENU_KEYS.includes(key)) return;
820
+ blankMenuSettings = { ...blankMenuSettings, [key]: enabled === true };
821
+ try {
822
+ globalThis.localStorage?.setItem(BLANK_MENU_KEY, JSON.stringify(blankMenuSettings));
823
+ } catch {
824
+ /* 隐私模式写不进去也照样生效到本次会话 */
825
+ }
826
+ publishBlankMenu();
827
+ }
828
+
829
+ /** 该项默认开启:只有显式存成 false 才关闭。 */
830
+ function blankOption(key) {
831
+ const value = blankMenuSettings[key];
832
+ if (typeof value === 'boolean') return value;
833
+ return BLANK_MENU_DEFAULTS[key] !== false;
834
+ }
835
+
736
836
  /* ------------------------------------- 打开方式:应用列表与二级开关 */
737
837
 
738
838
  function readStoredChildren() {
@@ -1520,6 +1620,13 @@ window.__ModuleLoader__.load({
1520
1620
  ]],
1521
1621
  ];
1522
1622
 
1623
+ /** 会话空白处右键菜单的开关;顺序跟随工具条,这里只管总开关。 */
1624
+ const BLANK_MENU_ROWS = [
1625
+ ['enabled', 'settings.blankMenu.enabled.label', 'settings.blankMenu.enabled.hint'],
1626
+ ['keepMoreLast', 'settings.blankMenu.keepMoreLast.label', 'settings.blankMenu.keepMoreLast.hint'],
1627
+ ['showCostMeter', 'settings.blankMenu.showCostMeter.label', 'settings.blankMenu.showCostMeter.hint'],
1628
+ ];
1629
+
1523
1630
  /**
1524
1631
  * 设置导航图标(DSH 0.2.2 的 `settings.section.icon`)。
1525
1632
  * 宿主只给 { size, active },颜色由导航按钮的 currentColor 决定;
@@ -1550,6 +1657,7 @@ window.__ModuleLoader__.load({
1550
1657
  function SettingsSection({ t }) {
1551
1658
  const items = useItems();
1552
1659
  const ellipsis = useEllipsis();
1660
+ const blank = useBlankMenu();
1553
1661
  const children = useChildren();
1554
1662
  const apps = useApps();
1555
1663
  usePinned();
@@ -1655,6 +1763,24 @@ window.__ModuleLoader__.load({
1655
1763
  ))),
1656
1764
  ));
1657
1765
 
1766
+ const blankRows = BLANK_MENU_ROWS.map(([key, labelKey, hintKey]) => h('div', {
1767
+ className: 'dsrm-settings-row',
1768
+ key,
1769
+ },
1770
+ h('div', { className: 'dsrm-settings-copy' },
1771
+ h('div', { className: 'dsrm-settings-label' }, t(labelKey)),
1772
+ h('div', { className: 'dsrm-settings-hint' }, t(hintKey)),
1773
+ ),
1774
+ h('div', { className: 'dsrm-settings-controls' },
1775
+ jsx(Switch, {
1776
+ checked: blank[key] !== false,
1777
+ disabled: key === 'keepMoreLast' && blank.enabled === false,
1778
+ label: t(labelKey),
1779
+ onChange: (next) => setBlankMenu(key, next),
1780
+ }),
1781
+ ),
1782
+ ));
1783
+
1658
1784
  return h('div', { className: 'dsrm-settings' },
1659
1785
  h('h2', { className: 'dsrm-settings-title' }, t('settings.title')),
1660
1786
  anyConflict ? h('div', { className: 'dsrm-settings-note' }, t('settings.conflictNote')) : null,
@@ -1662,6 +1788,9 @@ window.__ModuleLoader__.load({
1662
1788
  h('h2', { className: 'dsrm-settings-title dsrm-settings-subtitle' }, t('settings.ellipsisTitle')),
1663
1789
  h('div', { className: 'dsrm-settings-note' }, t('settings.ellipsisHint')),
1664
1790
  h('div', { className: 'dsrm-settings-groups' }, ellipsisGroups),
1791
+ h('h2', { className: 'dsrm-settings-title dsrm-settings-subtitle' }, t('settings.blankMenuTitle')),
1792
+ h('div', { className: 'dsrm-settings-note' }, t('settings.blankMenuNote')),
1793
+ h('div', { className: 'dsrm-settings-list' }, blankRows),
1665
1794
  );
1666
1795
  }
1667
1796
 
@@ -1947,6 +2076,700 @@ window.__ModuleLoader__.load({
1947
2076
  showWorkspaceMenu(props, x, y, mode);
1948
2077
  }
1949
2078
 
2079
+ /* ------------------------------------------ 会话正文空白处右键菜单 */
2080
+
2081
+ /**
2082
+ * 右上角工具条的三组扩展点。顺序无关紧要,最终以屏幕坐标为准。
2083
+ * corner 槽是右侧栏收起时出现的「展开右侧栏」等按钮。
2084
+ * section 只用于在空白菜单里分组画淡色横线,和宿主的扩展点边界一致。
2085
+ */
2086
+ const HEADER_SLOT_SELECTORS = [
2087
+ { section: 'actions', selector: '[data-slot="conversation.session.header.actions"]' },
2088
+ { section: 'utilities', selector: '[data-slot="conversation.session.header.utilities"]' },
2089
+ { section: 'corner', selector: '[data-slot="conversation.session.header.corner"]' },
2090
+ ];
2091
+ /** 这些区域里的右键不归空白菜单管:工具条、输入框与工具卡片本身。 */
2092
+ const BLANK_BLOCKERS = [
2093
+ '[data-slot="conversation.header"]',
2094
+ '[data-slot="conversation.session.header"]',
2095
+ '[data-slot="conversation.composer"]',
2096
+ '[data-slot="tool.call.toolview"]',
2097
+ ].join(',');
2098
+ /** 可交互节点:空白处右键不抢它们的原生菜单。 */
2099
+ const BLANK_INTERACTIVE = [ 'button',
2100
+ 'a[href]',
2101
+ 'input',
2102
+ 'textarea',
2103
+ 'select',
2104
+ 'summary',
2105
+ 'label',
2106
+ '[contenteditable="true"]',
2107
+ '[role="button"]',
2108
+ '[role="link"]',
2109
+ '[role="menuitem"]',
2110
+ '[role="menu"]',
2111
+ '[role="tab"]',
2112
+ '[role="checkbox"]',
2113
+ '[role="switch"]',
2114
+ '[role="radio"]',
2115
+ '[role="slider"]',
2116
+ '[role="combobox"]',
2117
+ '[role="textbox"]',
2118
+ '[aria-haspopup]',
2119
+ ].join(',');
2120
+ /** 消息正文里仍然让位的控件:输入类与链接用浏览器/宿主的原生菜单。 */
2121
+ const BLANK_MESSAGE_CONTROLS = [
2122
+ 'input',
2123
+ 'textarea',
2124
+ 'select',
2125
+ 'a[href]',
2126
+ '[contenteditable="true"]',
2127
+ ].join(',');
2128
+
2129
+ /** 节点是否有非零面积且和视口相交。 */
2130
+ function isOnScreen(node) {
2131
+ if (node === null || node === undefined || typeof node.getBoundingClientRect !== 'function') return false;
2132
+ const rect = node.getBoundingClientRect();
2133
+ if (rect.width <= 0 || rect.height <= 0) return false;
2134
+ const viewportWidth = typeof window.innerWidth === 'number' ? window.innerWidth : 0;
2135
+ const viewportHeight = typeof window.innerHeight === 'number' ? window.innerHeight : 0;
2136
+ return rect.bottom > 0 && rect.right > 0 && rect.top < viewportHeight && rect.left < viewportWidth;
2137
+ }
2138
+
2139
+ /** 找当前可见的会话头部(多标签时可能有多个,取视口内面积最大的那个)。 */
2140
+ function activeSessionHeader() {
2141
+ if (typeof document.querySelectorAll !== 'function') return null;
2142
+ let best = null;
2143
+ let bestArea = 0;
2144
+ for (const slot of document.querySelectorAll('[data-slot="conversation.session.header"]')) {
2145
+ const header = (typeof slot.closest === 'function' ? slot.closest('header') : null) ?? slot.parentElement;
2146
+ if (header === null || header === undefined || typeof header.getBoundingClientRect !== 'function') continue;
2147
+ const rect = header.getBoundingClientRect();
2148
+ if (rect.width <= 0 || rect.height <= 0) continue;
2149
+ const width = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));
2150
+ const height = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
2151
+ const area = width * height;
2152
+ if (area > bestArea) {
2153
+ bestArea = area;
2154
+ best = header;
2155
+ }
2156
+ }
2157
+ return best;
2158
+ }
2159
+
2160
+ /** 按钮的可读标签:aria-label / title 优先,最后才用文本。 */
2161
+ function headerButtonLabel(button) {
2162
+ const value = button.getAttribute('aria-label')
2163
+ || button.getAttribute('title')
2164
+ || button.textContent
2165
+ || '';
2166
+ return String(value).replace(/\s+/g, ' ').trim();
2167
+ }
2168
+
2169
+ /** 把按钮里的图标抄成可序列化的形状;MenuItemButton 的 icon 接受任意 React 节点。 */
2170
+ function headerButtonIcon(button) {
2171
+ const svg = typeof button.querySelector === 'function' ? button.querySelector('svg') : null;
2172
+ if (svg !== null && typeof svg.outerHTML === 'string' && svg.outerHTML !== '') {
2173
+ return { html: svg.outerHTML };
2174
+ }
2175
+ const img = typeof button.querySelector === 'function' ? button.querySelector('img[src]') : null;
2176
+ if (img !== null) {
2177
+ const src = img.getAttribute('src');
2178
+ if (typeof src === 'string' && src !== '') return { src };
2179
+ }
2180
+ return null;
2181
+ }
2182
+
2183
+ function isMoreTrigger(button, label) {
2184
+ if (MORE_LABEL_RE.test(label)) return true;
2185
+ const className = typeof button.className === 'string' ? button.className : '';
2186
+ return /morebutton/i.test(className);
2187
+ }
2188
+
2189
+ /**
2190
+ * 从按钮的 fiber 往上找宿主 Menu 的控制器(items + onSelect)。
2191
+ * 「更多操作」按钮的 props 本身只有 onClick,真正的菜单项和选择回调挂在
2192
+ * 宿主的 Menu 组件上;拿到后就能在自己的子菜单里直接调用,而不必点开右上角。
2193
+ */
2194
+ function findHeaderMenuController(button) {
2195
+ try {
2196
+ let fiber = fiberOf(button);
2197
+ for (let depth = 0; depth < 8 && fiber !== null && fiber !== undefined; depth += 1) {
2198
+ const props = fiber.memoizedProps;
2199
+ if (props !== null && typeof props === 'object'
2200
+ && Array.isArray(props.items) && props.items.length > 0
2201
+ && typeof props.onSelect === 'function') {
2202
+ return { items: props.items, onSelect: props.onSelect };
2203
+ }
2204
+ fiber = fiber.return;
2205
+ }
2206
+ } catch {
2207
+ /* ignore */
2208
+ }
2209
+ return null;
2210
+ }
2211
+
2212
+ /**
2213
+ * 宿主的「打开方式」是一整个 split 控件(主按钮 + 更多打开方式箭头)。
2214
+ * 空白菜单要把它收成和原生右键一样的「置顶应用 + 打开方式子菜单」,
2215
+ * 所以收集时就标出来,后续整组替换;认不出来时保持逐按钮原样。
2216
+ */
2217
+ function isOpenWithSplit(unit, candidates) { if (candidates.length < 2) return false;
2218
+ const className = typeof unit?.className === 'string' ? unit.className : '';
2219
+ if (/split/i.test(className)) return true;
2220
+ return candidates.some((candidate) => /更多打开方式|more open/i.test(headerButtonLabel(candidate)));
2221
+ }
2222
+
2223
+ /**
2224
+ * 工具条上的「本会话费用明细」一类统计入口(dsh-cost-meter 等)默认不列进菜单;
2225
+ * 这类按钮只有文字没有图标,点开是费用/后台任务面板。
2226
+ */
2227
+ function isCostMeterButton(button, label) {
2228
+ const className = typeof button?.className === 'string' ? button.className : '';
2229
+ if (/cm-stat|cm-btn/i.test(className)) return true;
2230
+ return /费用明细|费用|cost|usage/i.test(label);
2231
+ }
2232
+
2233
+ /**
2234
+ * 收集工具条上的按钮,按屏幕从左到右排序;「更多」按开关压到末尾。
2235
+ * 只统计真实可见、可点、有标签的节点:隐藏槽位、被 organizer 收进「…」的
2236
+ * 按钮不会出现在这里(那正是用户配置后的结果)。
2237
+ */
2238
+ function collectHeaderToolbarItems(header, keepMoreLast) {
2239
+ const found = [];
2240
+ const seen = new Set();
2241
+ if (header === null || header === undefined || typeof header.querySelector !== 'function') return found;
2242
+
2243
+ for (const slot of HEADER_SLOT_SELECTORS) {
2244
+ const container = header.querySelector(slot.selector);
2245
+ if (container === null || container === undefined) continue;
2246
+ const units = typeof container.children === 'object' && container.children !== null
2247
+ ? Array.from(container.children)
2248
+ : [];
2249
+ for (const unit of units) {
2250
+ const candidates = [];
2251
+ if (unit !== null && unit !== undefined && typeof unit.matches === 'function'
2252
+ && unit.matches(BLANK_INTERACTIVE)) {
2253
+ candidates.push(unit);
2254
+ }
2255
+ if (unit !== null && unit !== undefined && typeof unit.querySelectorAll === 'function') {
2256
+ for (const node of unit.querySelectorAll(BLANK_INTERACTIVE)) {
2257
+ if (node !== unit) candidates.push(node);
2258
+ }
2259
+ }
2260
+ const openWith = isOpenWithSplit(unit, candidates);
2261
+ for (const button of candidates) {
2262
+ if (seen.has(button)) continue;
2263
+ if (button.disabled === true) continue;
2264
+ if (button.getAttribute('aria-disabled') === 'true') continue;
2265
+ if (!isOnScreen(button)) continue;
2266
+ const label = headerButtonLabel(button);
2267
+ if (label === '') continue;
2268
+ seen.add(button);
2269
+ const rect = button.getBoundingClientRect();
2270
+ const more = isMoreTrigger(button, label);
2271
+ found.push({
2272
+ button,
2273
+ label,
2274
+ icon: headerButtonIcon(button),
2275
+ more,
2276
+ menu: more ? findHeaderMenuController(button) : null,
2277
+ openWith,
2278
+ cost: isCostMeterButton(button, label),
2279
+ section: slot.section,
2280
+ left: rect.left,
2281
+ top: rect.top,
2282
+ });
2283
+ }
2284
+ }
2285
+ }
2286
+
2287
+ found.sort((a, b) => (a.left - b.left) || (a.top - b.top));
2288
+ if (!keepMoreLast) return found;
2289
+ return [...found.filter((item) => !item.more), ...found.filter((item) => item.more)];
2290
+ }
2291
+
2292
+ /** 有选中文字时让位给浏览器的选区右键菜单。 */
2293
+ function hasTextSelection() {
2294
+ try {
2295
+ if (typeof window === 'undefined' || typeof window.getSelection !== 'function') return false;
2296
+ const selection = window.getSelection();
2297
+ if (selection === null || selection === undefined) return false;
2298
+ return String(selection).trim() !== '';
2299
+ } catch {
2300
+ return false;
2301
+ }
2302
+ }
2303
+
2304
+ /**
2305
+ * 右键是否落在会话正文里(工具条、输入框、工具卡片之外,且没有选中文字)。
2306
+ * 消息正文(思考过程、回复、工具摘要……)放宽到只避开输入控件与链接:
2307
+ * 思考标题本身是 role=button,但右键它也应该出菜单。
2308
+ */
2309
+ function isBlankConversationTarget(target) {
2310
+ if (target === null || target === undefined) return false;
2311
+ const start = target.nodeType === 3 ? target.parentElement : target;
2312
+ if (start === null || start === undefined || typeof start.closest !== 'function') return false;
2313
+ if (start.closest('[data-row-key]') !== null) return false;
2314
+ if (start.closest('[data-slot="conversation.session"]') === null) return false;
2315
+ if (start.closest(BLANK_BLOCKERS) !== null) return false;
2316
+ if (hasTextSelection()) return false;
2317
+ if (start.closest('[data-slot="conversation.chat.node"]') !== null) {
2318
+ return start.closest(BLANK_MESSAGE_CONTROLS) === null;
2319
+ }
2320
+ if (start.closest(BLANK_INTERACTIVE) !== null) return false;
2321
+ return true;
2322
+ }
2323
+
2324
+ function headerIconNode(icon) {
2325
+ if (icon === null || icon === undefined) return undefined;
2326
+ if (typeof icon.html === 'string' && icon.html !== '') {
2327
+ return h('span', {
2328
+ className: 'dsrm-header-icon',
2329
+ 'aria-hidden': 'true',
2330
+ dangerouslySetInnerHTML: { __html: icon.html },
2331
+ });
2332
+ }
2333
+ if (typeof icon.src === 'string' && icon.src !== '') {
2334
+ return h('img', { className: 'dsrm-header-icon-img', src: icon.src, alt: '' });
2335
+ }
2336
+ return undefined;
2337
+ }
2338
+
2339
+ /** 当前正在看的会话:先看侧栏高亮行,没有就看主视图保留的会话。 */
2340
+ function selectedSessionId() {
2341
+ try {
2342
+ if (typeof document.querySelector === 'function') {
2343
+ const row = document.querySelector('[data-row-key^="session:"][aria-selected="true"]');
2344
+ const key = typeof row?.getAttribute === 'function' ? row.getAttribute('data-row-key') : '';
2345
+ if (typeof key === 'string' && key.startsWith('session:')) return key.slice('session:'.length);
2346
+ }
2347
+ } catch {
2348
+ /* ignore */
2349
+ }
2350
+ const sessions = serviceOf(rootCtx, 'sessions');
2351
+ const byId = sessions?.list?.getSnapshot?.()?.byId;
2352
+ if (byId === null || byId === undefined || typeof byId !== 'object') return '';
2353
+ for (const row of Object.values(byId)) {
2354
+ if (typeof row?.id === 'string' && (row?.retainedBy?.mainView ?? 0) > 0) return row.id;
2355
+ }
2356
+ return '';
2357
+ }
2358
+
2359
+ /**
2360
+ * 空白菜单里的「打开方式」组:与原生工作区 / 会话右键同一套数据 ——
2361
+ * 置顶应用直接平铺,其余收进「打开方式」子菜单;开关、置顶与子项都在设置页配置。
2362
+ */
2363
+ function BlankOpenWithRows({ t, cwd, separatorBefore, onClose }) {
2364
+ const apps = useApps();
2365
+ useChildren();
2366
+ usePinned();
2367
+ const [open, setOpen] = useState(false);
2368
+ const [box, setBox] = useState(null);
2369
+ const hostRef = useRef(null);
2370
+ const closeTimer = useRef(0);
2371
+
2372
+ useEffect(() => {
2373
+ if (!open) return undefined;
2374
+ const dismiss = (event) => {
2375
+ const host = hostRef.current;
2376
+ if (host !== null && host.contains(event.target)) return;
2377
+ setOpen(false);
2378
+ };
2379
+ const onKeyDown = (event) => {
2380
+ if (event.key === 'Escape') setOpen(false);
2381
+ };
2382
+ document.addEventListener('mousedown', dismiss, true);
2383
+ document.addEventListener('keydown', onKeyDown, true);
2384
+ return () => {
2385
+ document.removeEventListener('mousedown', dismiss, true);
2386
+ document.removeEventListener('keydown', onKeyDown, true);
2387
+ };
2388
+ }, [open]);
2389
+
2390
+ // MenuSurface 不保证转发 ref,按条目数估算高度即可(列表本身会滚动)。
2391
+ useEffect(() => {
2392
+ if (!open) return;
2393
+ const host = hostRef.current;
2394
+ if (host === null) return;
2395
+ const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2396
+ const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2397
+ const height = Math.min(380, Math.max(40, count * 34 + 8));
2398
+ setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2399
+ }, [open, apps.status, apps.ids]);
2400
+
2401
+ useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2402
+
2403
+ const openNow = () => {
2404
+ window.clearTimeout(closeTimer.current);
2405
+ if (!open && hostRef.current !== null) {
2406
+ const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2407
+ const count = apps.ids.filter((id) => childOn(id) && !pinnedIds.has(id)).length;
2408
+ const height = Math.min(380, Math.max(40, count * 34 + 8));
2409
+ setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2410
+ }
2411
+ setOpen(true);
2412
+ ensureApps();
2413
+ };
2414
+ const closeSoon = () => {
2415
+ window.clearTimeout(closeTimer.current);
2416
+ closeTimer.current = window.setTimeout(() => setOpen(false), 160);
2417
+ };
2418
+ const pick = (appId) => {
2419
+ setOpen(false);
2420
+ onClose();
2421
+ openApplication(appId, cwd).then((ok) => {
2422
+ if (!ok) toast(t('openFailed'));
2423
+ });
2424
+ };
2425
+
2426
+ const pinnedIds = new Set(effectivePinned(apps.ids).filter((id) => childOn(id)));
2427
+ const enabledApps = apps.ids.filter((id) => childOn(id));
2428
+ const submenuApps = enabledApps.filter((id) => !pinnedIds.has(id));
2429
+
2430
+ let panelBody;
2431
+ if (apps.status === 'error') {
2432
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, t('settings.appsError'));
2433
+ } else if (apps.ids.length === 0) {
2434
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, apps.status === 'loading' ? t('settings.appsLoading') : t('settings.appsUnavailable'));
2435
+ } else if (submenuApps.length === 0) {
2436
+ panelBody = h('div', { className: 'dsrm-submenu-empty' }, t('settings.appsAllOff'));
2437
+ } else {
2438
+ panelBody = submenuApps.map((id) => jsx(MenuItemButton, {
2439
+ key: id,
2440
+ onSelect: () => pick(id),
2441
+ icon: appIcon(id),
2442
+ children: appLabel(id, t),
2443
+ }));
2444
+ }
2445
+
2446
+ return h(Fragment, null,
2447
+ separatorBefore ? h('div', { className: 'dsrm-sep', role: 'separator' }) : null,
2448
+ Array.from(pinnedIds).map((id) => jsx(MenuItemButton, {
2449
+ key: `pinned-${id}`,
2450
+ onSelect: () => pick(id),
2451
+ icon: appIcon(id),
2452
+ children: t('openWithApp', { name: appLabel(id, t) }),
2453
+ })),
2454
+ h('div', {
2455
+ className: 'dsrm-openwith',
2456
+ ref: hostRef,
2457
+ onPointerOver: openNow,
2458
+ onPointerEnter: openNow,
2459
+ onPointerLeave: closeSoon,
2460
+ },
2461
+ jsx(MenuItemButton, {
2462
+ onSelect: () => {
2463
+ if (open) setOpen(false);
2464
+ else openNow();
2465
+ },
2466
+ icon: jsx(IconRightUpOutlineRegular, { size: 14 }),
2467
+ children: h('span', { className: 'dsrm-host-label' },
2468
+ h('span', { className: 'dsrm-host-label-text' }, t('openWith')),
2469
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2470
+ ),
2471
+ }),
2472
+ open ? h(MenuSurface, {
2473
+ role: 'group',
2474
+ 'aria-label': t('openWith'),
2475
+ className: 'dsrm-submenu',
2476
+ style: {
2477
+ left: box?.left ?? 0,
2478
+ top: box?.top ?? 0,
2479
+ visibility: box === null ? 'hidden' : 'visible',
2480
+ },
2481
+ }, panelBody) : null,
2482
+ ),
2483
+ );
2484
+ }
2485
+
2486
+ /**
2487
+ * dsh-sidebar-organizer 把「更多操作」弹层的行序存在 localStorage 里(按行文字),
2488
+ * 只对宿主弹层写 CSS `order`。自己的二级菜单必须按同一份顺序排,才能看起来联动。
2489
+ */
2490
+ function organizerMenuOrder() {
2491
+ try {
2492
+ const raw = window.localStorage.getItem(ORGANIZER_MENU_ORDER_KEY);
2493
+ if (raw === null) return [];
2494
+ const parsed = JSON.parse(raw);
2495
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === 'string') : [];
2496
+ } catch {
2497
+ return [];
2498
+ }
2499
+ }
2500
+
2501
+ function orderMoreItems(entries) {
2502
+ const saved = organizerMenuOrder();
2503
+ if (saved.length === 0) return entries;
2504
+ const rank = new Map(saved.map((label, index) => [label, index]));
2505
+ return entries
2506
+ .map((entry, index) => ({ entry, index, rank: rank.has(entry.label) ? rank.get(entry.label) : saved.length }))
2507
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
2508
+ .map((item) => item.entry);
2509
+ }
2510
+
2511
+ /**
2512
+ * 空白菜单里的「更多操作」:直接读宿主 Menu 的 items / onSelect,
2513
+ * 在自己的子菜单里复刻那几项(复制 / 下载 / 反馈……),不再点开右上角的「…」。
2514
+ */
2515
+ function BlankMoreRows({ label, icon, controller, separatorBefore, onClose, children }) {
2516
+ const [open, setOpen] = useState(false);
2517
+ const [box, setBox] = useState(null);
2518
+ const hostRef = useRef(null);
2519
+ const closeTimer = useRef(0);
2520
+ const entries = orderMoreItems(controller.items);
2521
+
2522
+ useEffect(() => {
2523
+ if (!open) return undefined;
2524
+ const dismiss = (event) => {
2525
+ const host = hostRef.current;
2526
+ if (host !== null && host.contains(event.target)) return;
2527
+ setOpen(false);
2528
+ };
2529
+ const onKeyDown = (event) => {
2530
+ if (event.key === 'Escape') setOpen(false);
2531
+ };
2532
+ document.addEventListener('mousedown', dismiss, true);
2533
+ document.addEventListener('keydown', onKeyDown, true);
2534
+ return () => {
2535
+ document.removeEventListener('mousedown', dismiss, true);
2536
+ document.removeEventListener('keydown', onKeyDown, true);
2537
+ };
2538
+ }, [open]);
2539
+
2540
+ useEffect(() => {
2541
+ if (!open) return;
2542
+ const host = hostRef.current;
2543
+ if (host === null) return;
2544
+ const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2545
+ setBox(submenuGeometry(host.getBoundingClientRect(), { width: 220, height }));
2546
+ }, [open, controller, entries.length]);
2547
+
2548
+ useEffect(() => () => window.clearTimeout(closeTimer.current), []);
2549
+
2550
+ const openNow = () => {
2551
+ window.clearTimeout(closeTimer.current);
2552
+ if (!open && hostRef.current !== null) {
2553
+ const height = Math.min(320, Math.max(40, entries.length * 34 + 8));
2554
+ setBox(submenuGeometry(hostRef.current.getBoundingClientRect(), { width: 220, height }));
2555
+ }
2556
+ setOpen(true);
2557
+ };
2558
+ const closeSoon = () => {
2559
+ window.clearTimeout(closeTimer.current);
2560
+ closeTimer.current = window.setTimeout(() => setOpen(false), 160);
2561
+ };
2562
+ const pick = (id) => {
2563
+ setOpen(false);
2564
+ onClose();
2565
+ try {
2566
+ controller.onSelect(id);
2567
+ } catch (error) {
2568
+ console.warn('[dsh-sidebar-row-menu] header menu action failed:', error);
2569
+ }
2570
+ };
2571
+
2572
+ return h(Fragment, null,
2573
+ separatorBefore ? h('div', { className: 'dsrm-sep', role: 'separator' }) : null,
2574
+ h('div', {
2575
+ className: 'dsrm-openwith dsrm-more',
2576
+ ref: hostRef,
2577
+ onPointerOver: openNow,
2578
+ onPointerEnter: openNow,
2579
+ onPointerLeave: closeSoon,
2580
+ },
2581
+ jsx(MenuItemButton, {
2582
+ onSelect: () => {
2583
+ if (open) setOpen(false);
2584
+ else openNow();
2585
+ },
2586
+ icon: headerIconNode(icon),
2587
+ children: h('span', { className: 'dsrm-host-label' },
2588
+ h('span', { className: 'dsrm-host-label-text' }, children ?? label),
2589
+ jsx(IconChevronRightOutlineRegular, { size: 12, className: 'dsrm-submenu-chevron' }),
2590
+ ),
2591
+ }),
2592
+ open ? h(MenuSurface, {
2593
+ role: 'group',
2594
+ 'aria-label': label,
2595
+ className: 'dsrm-submenu',
2596
+ style: {
2597
+ left: box?.left ?? 0,
2598
+ top: box?.top ?? 0,
2599
+ visibility: box === null ? 'hidden' : 'visible',
2600
+ },
2601
+ }, entries.map((entry, index) => jsx(MenuItemButton, {
2602
+ key: typeof entry.id === 'string' ? entry.id : `more-item-${index}`,
2603
+ onSelect: () => pick(entry.id),
2604
+ disabled: entry.disabled === true,
2605
+ icon: entry.icon === null || entry.icon === undefined
2606
+ ? undefined
2607
+ : h('span', { className: 'dsrm-header-icon' }, entry.icon),
2608
+ children: entry.label,
2609
+ }))) : null,
2610
+ ),
2611
+ );
2612
+ }
2613
+
2614
+ /**
2615
+ * 工具条按钮的纵向菜单。每条点下去是「真的按一下」原按钮:
2616
+ * 先关菜单再派发 click,宿主弹出层才能正常锚定到原按钮。
2617
+ * 「打开方式」整组换成和原生右键一致的置顶应用 + 子菜单,其余按钮原样复刻。
2618
+ */
2619
+ function HeaderToolbarMenu({ x, y, items, t, sessionId, onClose }) {
2620
+ const hostRef = useRef(null);
2621
+ const [pos, setPos] = useState(null);
2622
+ const itemsConfig = useItems();
2623
+ const blankMenu = useBlankMenu();
2624
+ const apps = useApps();
2625
+ useChildren();
2626
+ usePinned();
2627
+
2628
+ useEffect(() => {
2629
+ const host = hostRef.current;
2630
+ const dismiss = (event) => {
2631
+ if (host !== null && host.contains(event.target)) return;
2632
+ onClose();
2633
+ };
2634
+ const onKeyDown = (event) => {
2635
+ if (event.key === 'Escape') onClose();
2636
+ };
2637
+ document.addEventListener('mousedown', dismiss, true);
2638
+ document.addEventListener('keydown', onKeyDown, true);
2639
+ return () => {
2640
+ document.removeEventListener('mousedown', dismiss, true);
2641
+ document.removeEventListener('keydown', onKeyDown, true);
2642
+ };
2643
+ }, [onClose]);
2644
+
2645
+ useLayoutEffect(() => {
2646
+ const host = hostRef.current;
2647
+ if (host === null) return;
2648
+ const rect = host.getBoundingClientRect();
2649
+ const margin = 8;
2650
+ const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin));
2651
+ const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin));
2652
+ setPos((prev) => (prev !== null && prev.left === left && prev.top === top ? prev : { left, top }));
2653
+ }, [x, y, items]);
2654
+
2655
+ const cwd = typeof sessionId === 'string' && sessionId !== '' ? sessionCwd(rootCtx, sessionId) : '';
2656
+ const enabledApps = apps.ids.filter((id) => childOn(id));
2657
+ // 工具条上的 split「打开方式」只有在能拿到会话目录、且设置里开着时才接管;
2658
+ // 否则保留宿主的两个原按钮,菜单仍然是工具条的镜像。
2659
+ const ownOpenWith = itemsConfig.openWith !== false
2660
+ && cwd !== ''
2661
+ && enabledApps.length > 0
2662
+ && items.some((item) => item.openWith);
2663
+
2664
+ const rows = [];
2665
+ let section = '';
2666
+ const takeSeparator = (next) => {
2667
+ const separator = section !== '' && section !== next;
2668
+ section = next;
2669
+ return separator;
2670
+ };
2671
+ let openWithDone = false;
2672
+ items.forEach((item, index) => {
2673
+ // 费用/用量统计入口默认隐藏,设置里打开「显示费用明细」才出现。
2674
+ if (item.cost && blankMenu.showCostMeter !== true) return;
2675
+ // 设置里关掉「打开方式」时,空白菜单也不出现这一组,和工作区 / 会话右键一致。
2676
+ if (item.openWith && itemsConfig.openWith === false) return;
2677
+ if (item.openWith && ownOpenWith) {
2678
+ if (!openWithDone) {
2679
+ openWithDone = true;
2680
+ rows.push(jsx(BlankOpenWithRows, {
2681
+ key: 'open-with',
2682
+ t,
2683
+ cwd,
2684
+ separatorBefore: takeSeparator('open-with'),
2685
+ onClose,
2686
+ }));
2687
+ }
2688
+ return;
2689
+ }
2690
+ if (item.more && item.menu !== null && item.menu !== undefined) {
2691
+ rows.push(jsx(BlankMoreRows, {
2692
+ key: `${index}:more`,
2693
+ label: item.label,
2694
+ icon: item.icon,
2695
+ // 宿主弹层的行序由 dsh-sidebar-organizer 用 CSS order 控制,这里的
2696
+ // 子菜单是自绘的,必须按它保存的顺序重排才能保持联动。
2697
+ controller: { items: orderMoreItems(item.menu.items), onSelect: item.menu.onSelect },
2698
+ separatorBefore: takeSeparator('more'),
2699
+ onClose,
2700
+ children: item.label,
2701
+ }));
2702
+ return;
2703
+ }
2704
+ rows.push(jsx(MenuItemButton, {
2705
+ key: `${index}:${item.label}`,
2706
+ separatorBefore: takeSeparator(item.openWith ? 'open-with' : (item.more ? 'more' : (item.section || 'toolbar'))),
2707
+ onSelect: () => {
2708
+ onClose();
2709
+ // 等菜单卸载完再点:否则宿主的弹出层会被面板的全局 dismiss 立刻收掉。
2710
+ window.setTimeout(() => {
2711
+ try {
2712
+ item.button.click();
2713
+ } catch (error) {
2714
+ console.warn('[dsh-sidebar-row-menu] toolbar action click failed:', error);
2715
+ }
2716
+ }, 0);
2717
+ },
2718
+ icon: headerIconNode(item.icon),
2719
+ children: item.label,
2720
+ }));
2721
+ });
2722
+
2723
+ return h('div', {
2724
+ className: 'dsrm-menu',
2725
+ ref: hostRef,
2726
+ style: {
2727
+ left: pos?.left ?? x,
2728
+ top: pos?.top ?? y,
2729
+ visibility: pos === null ? 'hidden' : 'visible',
2730
+ },
2731
+ }, h(MenuSurface, { role: 'group', className: 'dsrm-menu-surface dsrm-header-menu' }, rows));
2732
+ }
2733
+
2734
+ function openHeaderToolbarMenu(items, x, y, sessionId = '') {
2735
+ if (items.length === 0) return;
2736
+ closeMenu();
2737
+ menuHost = document.createElement('div');
2738
+ menuHost.setAttribute(MENU_ATTR, 'header-toolbar');
2739
+ menuHost.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647';
2740
+ document.body.appendChild(menuHost);
2741
+ menuRoot = createRoot(menuHost);
2742
+ menuRoot.render(jsx(HeaderToolbarMenu, {
2743
+ x,
2744
+ y,
2745
+ items,
2746
+ t: localeOf(rootCtx),
2747
+ sessionId,
2748
+ onClose: () => closeMenu(),
2749
+ }));
2750
+ }
2751
+
2752
+ function onBlankConversationContextMenu(event) {
2753
+ if (!blankOption('enabled')) return;
2754
+ if (!isBlankConversationTarget(event.target)) return;
2755
+ const items = collectHeaderToolbarItems(activeSessionHeader(), blankOption('keepMoreLast'));
2756
+ if (items.length === 0) return;
2757
+ event.preventDefault();
2758
+ event.stopPropagation();
2759
+ event.stopImmediatePropagation?.();
2760
+ // 提前预热应用列表:下一次右键就能直接呈现置顶应用 + 子菜单。
2761
+ try {
2762
+ ensureApps();
2763
+ } catch {
2764
+ /* ignore */
2765
+ }
2766
+ try {
2767
+ openHeaderToolbarMenu(items, event.clientX, event.clientY, selectedSessionId());
2768
+ } catch (error) {
2769
+ console.warn('[dsh-sidebar-row-menu] blank context menu failed:', error);
2770
+ }
2771
+ }
2772
+
1950
2773
  /**
1951
2774
  * 行尾「…」按钮已经展开内置菜单时,用同一个按钮位置改挂本插件菜单。
1952
2775
  * 只在用户为工作区行开启了至少一个「…」附加项时接管;否则原生菜单原样工作。
@@ -2885,8 +3708,13 @@ window.__ModuleLoader__.load({
2885
3708
  .dsrm-submenu{position:fixed;z-index:1101;min-width:180px;max-width:300px;max-height:min(380px,64vh);overflow:auto;padding:4px}
2886
3709
  .dsrm-submenu-empty{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
2887
3710
  .dsrm-menu{position:fixed;z-index:2147483647}
2888
- .dsrm-menu-surface{min-width:210px;padding:4px}
2889
- .dsrm-sep{height:.5px;margin:3px 2px;background:var(--dsw-alias-border-l2)}
3711
+ .dsrm-menu-surface{box-sizing:border-box;min-width:144px;padding:4px}
3712
+ .dsrm-header-menu{max-height:min(70vh,520px);overflow:auto}
3713
+ .dsrm-header-icon{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:inherit}
3714
+ .dsrm-header-icon svg{width:14px;height:14px;display:block}
3715
+ .dsrm-header-icon-img{width:14px;height:14px;border-radius:3px;display:block;object-fit:contain}
3716
+ .dsrm-openwith{display:block}
3717
+ .dsrm-sep{height:.5px;margin:3px 8px;background:var(--dsw-alias-border-l2)}
2890
3718
  /* 150% 缩放等分数像素比下,宿主 0.5px 的分隔线会被摊成两行近乎透明,补到 1px 才看得见。 */
2891
3719
  @media (min-resolution:1.2dppx) and (max-resolution:1.99dppx){[role="menu"] [role="separator"],.dsrm-sep{height:1px}}
2892
3720
  .dsrm-app-icon{width:14px;height:14px;border-radius:3px;display:block;object-fit:contain}
@@ -2952,7 +3780,11 @@ window.__ModuleLoader__.load({
2952
3780
  if (start === null || start === undefined || typeof start.closest !== 'function') return;
2953
3781
 
2954
3782
  const row = start.closest('[data-row-key]');
2955
- if (row === null) return;
3783
+ if (row === null) {
3784
+ // 不在任何侧栏行上:正文空白处的右键交给工具条菜单。
3785
+ onBlankConversationContextMenu(event);
3786
+ return;
3787
+ }
2956
3788
  const rowKey = row.getAttribute('data-row-key') ?? '';
2957
3789
  if (!rowKey.startsWith('workspace:') && !rowKey.startsWith('session:')) return;
2958
3790