dsh-sidebar-row-menu 1.7.0 → 1.9.0

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