dsh-better-workspace 0.37.2 → 0.38.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/README.md +3 -2
- package/README_EN.md +3 -2
- package/dsh.plugin.json +1 -1
- package/package.json +3 -2
- package/src/client.js +341 -44
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
<table>
|
|
56
56
|
<tr>
|
|
57
57
|
<td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="右键菜单"/></td>
|
|
58
|
-
<td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)
|
|
58
|
+
<td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)。<br/><b>v0.38.0 起接入官方统一右键菜单</b>:会话/工作区行由宿主共享菜单渲染,官方条目与插件补充项(移动到分组 / 移出分组 / 自定义外观 / 修改默认预设,排在分隔线之后)同席,官方新增条目会自动出现;分组行仍用插件菜单,旧宿主回退原菜单。</td>
|
|
59
59
|
</tr>
|
|
60
60
|
</table>
|
|
61
61
|
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
- **不丢原生能力**:会话行(打开/重命名/分叉/归档;**双击标题**重命名,v0.34.0 起;**右键是唯一菜单入口**——行内「⋯」已退役,悬停只留快捷钮,工作区行同样只留新会话钮,v0.35.1 起)、**官方行样式逐项对齐**(v0.34.0 起:恒定 16px 状态槽——运行转圈不再挤动标题、悬停 800ms 弹官方 HoverCard(标题/路径/时间/状态,可复制)、超长标题悬停匀速滚动、活动链工作区文件夹图标着蓝、拖放指示用官方斜纹图案)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标;v0.23.0 起直接渲染官方 `sidebar.session.row.leading` 座位的占用者,与官方行同源)、未分组会话兜底、界面文案跟随 DSH 的语言设置(内置 21 种语言,含简繁中文)。
|
|
74
74
|
- **新增项自动配色 + 图标(v0.25.0,默认关闭)**:设置卡里一个开关,打开后**新创建的会话**自动分到一个内置色、**新工作区**再额外分到一个图标,懒得逐个设置的人直接开箱即用。颜色来自**定死的 13 个槽位**,每个槽位配**两色**——浅色主题用深色系、深色主题用亮色系——按「洗牌袋 + 近期回避窗口」抽取:一轮之内绝不重复,跨轮也不会立刻撞上刚出过的 3 个,连续新建几项肉眼可辨。**可读性是真达标**:每个色在**对应主题**背景下的对比度都 **≥4.5**,即 WCAG **正文级 AA**(对比度是脚本真算的,测试逐色断言;浅色主题按比纯白更暗的悬停面、深色主题按最亮的面这种保守取值)。行里存的是**槽位 id(`auto:3`)而不是色值**,所以**切换深浅主题时颜色自动换成同色系的另一套**,既不用重新设置,也不会失配。**只影响打开开关之后新建的项**:你手动设置过外观的行(存的是 hex)永不被覆盖,关闭开关也不回收已分配的样式,同一项不会被二次随机。图标从既有图标候选集里取、按宿主实际导出过滤;**图标只给工作区**——会话行的那个位置是官方状态灯与日程角标,没有图标位。
|
|
75
75
|
- **官方新语义与快捷键都接上(v0.23.0,对齐 dsh 0.1.7-rc.2)**:① 归档筛选是官方新版三态直选(**隐藏已归档** / **全部对话(显示已归档)** / **仅显示已归档**);选「仅显示已归档」时**没有归档会话的工作区整行隐藏**,空列表显示「暂无已归档会话」+「查看其他会话」一键回退;② 官方快捷键 **⌥⌘K 搜索 / ⌥⌘O 添加工作区 / ⌥⌘N 新会话**(桌面端为 ⌘K / ⌘O / ⌘N)在本插件的自绘侧栏里**真的生效**——搜索键打开并聚焦本插件的搜索框、添加键走同一个官方目录流(原生宿主弹系统选择器,browse 宿主弹应用内入口提示),按钮文案还会带上宿主当前的键位;③ 首次使用的工作区显示为本地化的「默认工作区」,不再露出磁盘目录名。
|
|
76
|
-
- **URL/斜杠标题不拆层**:宿主自动生成的会话标题落地时若含 `/`(如复述了 URL),整串自动包上 `“”` 原样显示,绝不解析成层级——只对本次启动后**新建的会话**生效,并等命名稳定(约 20 秒,让原生 AI 命名先落地)才处理;存量会话与你手动命名的 `/` 分组标题永不被改动。引号内(含手动添加)的 `/` 永不切分,孤立引号只当普通字符;无引号但含 URL 的旧标题也有平铺兜底。**v0.37.2 起再加一道路径保护(issue #17)**:标题里引用绝对路径(`/Users/...`、`C:/...` 这类含至少两个 `/` 的路径 token)时,整条标题作为一个会话行显示,绝不拆成伪目录——存量会话与首条消息回显的标题同样生效,`web/前端`
|
|
76
|
+
- **URL/斜杠标题不拆层**:宿主自动生成的会话标题落地时若含 `/`(如复述了 URL),整串自动包上 `“”` 原样显示,绝不解析成层级——只对本次启动后**新建的会话**生效,并等命名稳定(约 20 秒,让原生 AI 命名先落地)才处理;存量会话与你手动命名的 `/` 分组标题永不被改动。引号内(含手动添加)的 `/` 永不切分,孤立引号只当普通字符;无引号但含 URL 的旧标题也有平铺兜底。**v0.37.2 起再加一道路径保护(issue #17)**:标题里引用绝对路径(`/Users/...`、`C:/...` 这类含至少两个 `/` 的路径 token)时,整条标题作为一个会话行显示,绝不拆成伪目录——存量会话与首条消息回显的标题同样生效,`web/前端` 这类正常的名称分组不受影响。**v0.37.3 起路径标题不再被 20 秒稳定期改成带引号的用户标题**:显示层已经平铺,原逻辑反而会把回显路径钉死并取消尚未落地的 AI 命名;现在直接放过,等 AI 总结名替换。会话分组仍默认开启,不需要的话可在视图菜单关掉。
|
|
77
77
|
- **新会话页的工作区选择器 = 侧栏那棵树(v0.22.0)**:会话空态页「工作区」按钮的弹层原本是官方的一层平铺标题列表(连磁盘目录嵌套都没有);本插件接管该座位后,弹层绘制与侧栏**同一棵**树——磁盘目录嵌套、「/」名称分组三态、单链合并全部读同一个视图 store,所以侧栏的视图选项一改,**这边立刻跟着变**;当前工作区带勾选标记,分组行可开合(选择器内本地记忆,默认全展开),标题按侧栏的层级叶子显示。底部「添加工作区」仍由**官方目录拾取占用者**承载(原生选择器 / 官方应用内浏览器),不是仿制品。
|
|
78
78
|
|
|
79
79
|
<table>
|
|
@@ -124,6 +124,7 @@ DSH 的侧边栏浏览区是一个公开插槽 `sidebar.workspaces`(single),官
|
|
|
124
124
|
- **dsh 0.1.6-alpha.1**:图标集与浏览器注入面都变了,本插件 **0.10.2 起自动适配**——图标网格只列本宿主真正导出的字形(退役的 `IconSendOutline16` 自动落到等价的 `IconSendOutline14`,已保存的旧选择不会变成空图标位),会话拖拽排序在宿主不再注入 `insertSessionBefore` 时改用浏览器本地顺序,**功能不丢**。升级 dsh 与升级插件谁先谁后都收敛。
|
|
125
125
|
- **dsh 0.1.6-alpha.2**:设置页重组——本插件 **0.12.0 起双座位**注册设置卡片(旧宿主的 设置→插件 卡片 + 新宿主 插件面板 bundle 页,按包名挂载);工作区树新增**磁盘目录嵌套层**(官方 workspace-tree 语义),添加工作区回归官方目录流(原生选择器 / 官方应用内浏览器)。
|
|
126
126
|
- **dsh 0.2.2-alpha.2**:自绘会话行渲染官方 `sidebar.session.row.trailing` 行尾座位(时间之后、置顶角标之前;空会话不渲染),官方实验性 worktree / PR 图标因此在插件接管侧栏后仍能出现;同时接受官方的空白会话复用语义(不传 `agentPreset` 时会复用同工作区任意 preset 的空白会话)。该座位按能力探测:老宿主没有它时行尾照常渲染,不会因此报错。兼容下限保持 `>=0.1.6-alpha.2`。
|
|
127
|
+
- **dsh 0.2.2-alpha.3**:会话/工作区行接入官方统一右键菜单(`contextMenus`)——官方条目与插件补充项同席,插件条目 order 700、排在其后并以分隔线隔开,官方今后新增的条目会自动出现。工作区分组 / 会话分组行是插件私有虚拟对象,仍用插件菜单;没有该服务的旧宿主整体回退本地菜单,兼容下限保持 `>=0.1.6-alpha.2`。
|
|
127
128
|
- **跨端**:web 与桌面共用同一套客户端资产;外观 / 开关可经设置卡片手动跨端同步,**折叠状态与会话本地序每端本地**(设计如此)。
|
|
128
129
|
|
|
129
130
|
## 限制与路线图
|
package/README_EN.md
CHANGED
|
@@ -55,7 +55,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
55
55
|
<table>
|
|
56
56
|
<tr>
|
|
57
57
|
<td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="Context menu"/></td>
|
|
58
|
-
<td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / customize; session & session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).</td>
|
|
58
|
+
<td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / customize; session & session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).<br/><b>Since v0.38.0 the shared host context menu owns session/workspace rows</b>: official entries and this plugin's extras (move to group / move out / customize / default preset, after a separator) share one popup, and future official entries appear automatically; group rows keep the plugin menu and older hosts fall back to it.</td>
|
|
59
59
|
</tr>
|
|
60
60
|
</table>
|
|
61
61
|
|
|
@@ -73,7 +73,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
73
73
|
- **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session (the + button always expands the workspace first so the new session is immediately visible), current-session highlight, official status dots (blue running ring / amber pending / green done), **status breathing lights** (dots hidden by collapse bubble outward: workspace/folder rows breathe on their icon in the status color — amber pending / green done, custom-glow labels breathe along; running breathes blue only on the session row itself and never relays; off-able in settings), **active-schedule badge** (alarm clock icon for sessions with active Schedule records; since v0.23.0 it renders the occupant of the shipped `sidebar.session.row.leading` seat, so it is the same mark the official row would show), ungrouped-session fallback, UI copy in 21 languages following the DSH language preference (Simplified and Traditional Chinese included).
|
|
74
74
|
- **Auto color + icon for new items (v0.25.0, off by default)**: one switch in the plugin settings card. Turn it on and every newly created session gets a built-in color while every new workspace additionally gets an icon — for anyone who would rather not style rows one by one. Colors come from **13 fixed slots**, each shipping **two colors** — a darker one for the light theme, a lighter one for the dark theme — drawn with a shuffle bag plus a recent-draw window: nothing repeats inside a cycle, a refill never starts with the last three colors, and items added in a row look clearly different. **The readability claim is real**: every entry keeps contrast **≥ 4.5** on its own theme's background, i.e. WCAG **normal-text AA** (computed by script and asserted per color in the test suite, measured against conservative surfaces: a hovered row darker than pure white in the light theme, the brightest surface in the dark theme). A row stores the **slot id (`auto:3`), not a hex**, so **switching between light and dark repaints it with the matching sibling color** — nothing to reconfigure and no mismatch. **Only items created after you turn it on are touched**: rows you styled by hand (stored as hex) are never overwritten, turning it off does not undo assignments, and an item is never randomized twice. Icons come from the existing icon candidate set, filtered by what the host actually exports — and **workspaces only**: the session row's cell belongs to the official status dot and schedule mark, so it has no icon slot.
|
|
75
75
|
- **Official semantics and shortcuts wired up (v0.23.0, aligned with dsh 0.1.7-rc.2)**: ① the archived filter is the shipped three-way pick (**Hide archived** / **All conversations (show archived)** / **Archived only**); under "Archived only" a workspace without archived sessions disappears entirely, and an empty list offers "No archived sessions yet" + **View other sessions** as the way back. ② The shipped shortcuts **⌥⌘K search / ⌥⌘O add workspace / ⌥⌘N new session** (⌘K / ⌘O / ⌘N on desktop) really work in this plugin's own sidebar — the search key opens and focuses this sidebar's filter field, the add key runs the very same official directory flow (native chooser on a native host, the in-app pointer on a browse host), and the buttons show the host's current keybindings. ③ A first-use workspace reads as the localized "Default workspace" instead of the on-disk directory name.
|
|
76
|
-
- **Slash-bearing titles never nest from URLs**: when a host-generated session title lands containing `/` (e.g. echoing a pasted URL), it is auto-wrapped in `“”` and shown verbatim — only for sessions **created after this launch**, and only once the name stays stable for ~20s (letting the native AI naming land first); pre-existing sessions and your manually named `/` groupings are never touched. Quotes (manual ones too) never split on `/`, and a lone quote is just an ordinary character; unquoted URL tails still fall back flat. **Since v0.37.2 there is also a path guard (issue #17)**: a title quoting an absolute path (`/Users/...`, `C:/...` — any token with at least two `/`s) is shown as ONE session row and never shredded into fake folders, including pre-existing sessions and auto-titles echoing the first message; normal name groups like `web/frontend` are unaffected. Session grouping stays on by default; turn it off in the view menu if you don't want it.
|
|
76
|
+
- **Slash-bearing titles never nest from URLs**: when a host-generated session title lands containing `/` (e.g. echoing a pasted URL), it is auto-wrapped in `“”` and shown verbatim — only for sessions **created after this launch**, and only once the name stays stable for ~20s (letting the native AI naming land first); pre-existing sessions and your manually named `/` groupings are never touched. Quotes (manual ones too) never split on `/`, and a lone quote is just an ordinary character; unquoted URL tails still fall back flat. **Since v0.37.2 there is also a path guard (issue #17)**: a title quoting an absolute path (`/Users/...`, `C:/...` — any token with at least two `/`s) is shown as ONE session row and never shredded into fake folders, including pre-existing sessions and auto-titles echoing the first message; normal name groups like `web/frontend` are unaffected. **Since v0.37.3 a path-shaped title is no longer rewritten into a quoted user title after the 20s window**: the display guard already flattens it, while that rename pinned the echoed title and cancelled an LLM name that had not landed yet — the effect now leaves it to the provider. Session grouping stays on by default; turn it off in the view menu if you don't want it.
|
|
77
77
|
- **The New Session workspace picker IS the sidebar tree (v0.22.0)**: the empty-state "Workspace" chip used to open the shipped one-level list of flat titles (no disk nesting at all). This plugin shadows that seat (lowest priority renders) and draws the **same** tree the sidebar draws — disk nesting, the tri-state "/" name layer, chain compression — all from the one view store, so changing a view option on the sidebar moves this popover immediately. The current workspace carries a check, folder rows fold (picker-local, fully expanded by default), and the pinned "Add workspace" action still renders the **shipped directory-picking occupant** (native chooser / official in-app browser) instead of a copy.
|
|
78
78
|
|
|
79
79
|
## Install
|
|
@@ -101,6 +101,7 @@ Same `dsh-better-*` family, zero overlap: [dsh-better-sidebar](https://github.co
|
|
|
101
101
|
- **dsh 0.1.6-alpha.1**: both the primitives icon set and the browser injection face changed; this plugin **adapts automatically since 0.10.2**. The icon grid only lists glyphs this host actually exports (the retired `IconSendOutline16` resolves to the equivalent `IconSendOutline14`, so a previously saved choice never turns into an empty icon slot), and session drag-to-reorder falls back to a browser-local order when the host no longer injects `insertSessionBefore` — **no feature is lost**. Either upgrade order (plugin first or dsh first) converges.
|
|
102
102
|
- **dsh 0.1.6-alpha.2**: the settings pages were reworked — since **0.12.0** this plugin registers its settings card on **both seats** (the pre-alpha.2 Settings → Plugins card and the alpha.2+ Plugins panel's bundle page, keyed by package name); the tree gains the **disk-nesting layer** (official workspace-tree semantics) and add-workspace rides the official directory flow (native chooser / official in-app browser).
|
|
103
103
|
- **dsh 0.2.2-alpha.2**: the hand-drawn session rows now render the shipped `sidebar.session.row.trailing` seat (after the time, before the pin badge; blank rows skip it), so the official experimental worktree / PR icons still appear after this plugin takes over the sidebar. The plugin also accepts the host's blank-session reuse semantics (without `agentPreset`, a blank from any preset in the same workspace may be reused). The seat is capability-probed: hosts without it keep rendering the row tail and never error. The compatibility floor stays `>=0.1.6-alpha.2`.
|
|
104
|
+
- **dsh 0.2.2-alpha.3**: session/workspace rows join the shipped unified context menu (`contextMenus`) — official entries and this plugin's extras share one popup, the extras sit at order 700 after a separator, and future official entries appear automatically. Workspace-group / session-group rows are plugin-private virtual objects and keep the plugin menu; older hosts without the service fall back to the local menu, and the compatibility floor stays `>=0.1.6-alpha.2`.
|
|
104
105
|
- **Across surfaces**: web and desktop share one set of client assets; appearance / switches sync manually through the settings card, while **collapse state and the local session order stay per-browser** by design.
|
|
105
106
|
|
|
106
107
|
## Limitations / roadmap
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-better-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树。Workspace tree for the DSH sidebar workspace list, nested by official disk folders with optional \"/\"-name grouping; the New Session workspace picker shows the same tree.",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-better-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树(同一份视图选项即时同步);添加工作区走官方目录流。Workspace tree for the DSH sidebar: disk-path nesting (official workspace-tree semantics), with optional \"/\"-name grouping (off by default), and the same tree in the New Session workspace picker; add-workspace rides the official directory flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
32
32
|
"@deepseek-ai/dsh-client-ui-conversation",
|
|
33
33
|
"@deepseek-ai/dsh-client-modules",
|
|
34
|
-
"@deepseek-ai/dsh-api-remotes"
|
|
34
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-context-menu"
|
|
35
36
|
],
|
|
36
37
|
"platform": "web"
|
|
37
38
|
}
|
package/src/client.js
CHANGED
|
@@ -5366,6 +5366,42 @@ window.__ModuleLoader__.load({
|
|
|
5366
5366
|
)
|
|
5367
5367
|
: HOST_SNAP_UNAVAILABLE
|
|
5368
5368
|
}
|
|
5369
|
+
// dsh 0.2.2-alpha.3: the shared context menu owns the popup, so its
|
|
5370
|
+
// captured target (not our local ctx) decides which row keeps the open
|
|
5371
|
+
// state. Capability probing stays inside the callbacks — old hosts have
|
|
5372
|
+
// no service and the hook degrades to a stable null.
|
|
5373
|
+
// Late-arriving shared menu: the row highlight subscribes to face.active,
|
|
5374
|
+
// but the face itself can be adopted AFTER the first render (soft inject).
|
|
5375
|
+
// A tiny module-level revision bus re-renders those rows once, so the
|
|
5376
|
+
// highlight starts following the official target without waiting for an
|
|
5377
|
+
// unrelated store tick.
|
|
5378
|
+
const menuFaceListeners = new Set()
|
|
5379
|
+
const subscribeMenuFace = (listener) => {
|
|
5380
|
+
menuFaceListeners.add(listener)
|
|
5381
|
+
return () => { menuFaceListeners.delete(listener) }
|
|
5382
|
+
}
|
|
5383
|
+
const notifyMenuFace = () => {
|
|
5384
|
+
for (const listener of Array.from(menuFaceListeners)) {
|
|
5385
|
+
try { listener() } catch { /* one stale row must not stop the rest */ }
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
5388
|
+
const useActiveContextMenu = (faceOf) => {
|
|
5389
|
+
const [, bumpFace] = React.useState(0)
|
|
5390
|
+
React.useEffect(() => {
|
|
5391
|
+
if (typeof faceOf === 'function' && faceOf()) return undefined
|
|
5392
|
+
return subscribeMenuFace(() => bumpFace((value) => value + 1))
|
|
5393
|
+
}, [])
|
|
5394
|
+
const face = typeof faceOf === 'function' ? faceOf() : null
|
|
5395
|
+
const active = face && face.active
|
|
5396
|
+
return HAS_USE_SYNC_EXTERNAL_STORE
|
|
5397
|
+
? React.useSyncExternalStore(
|
|
5398
|
+
(onChange) => (active && typeof active.subscribe === 'function' ? active.subscribe(onChange) : () => {}),
|
|
5399
|
+
() => {
|
|
5400
|
+
try { return active && typeof active.getSnapshot === 'function' ? active.getSnapshot() : null } catch { return null }
|
|
5401
|
+
},
|
|
5402
|
+
)
|
|
5403
|
+
: null
|
|
5404
|
+
}
|
|
5369
5405
|
|
|
5370
5406
|
// MANUAL cross-device sync (user-chosen, never automatic): the settings
|
|
5371
5407
|
// card offers a pull button ("从客户端获取" on the web / "从 Web 获取" in
|
|
@@ -6125,6 +6161,23 @@ window.__ModuleLoader__.load({
|
|
|
6125
6161
|
}
|
|
6126
6162
|
}
|
|
6127
6163
|
|
|
6164
|
+
// dsh 0.2.2-alpha.3 keyboard trigger, mirroring the shared menu's own
|
|
6165
|
+
// getTriggerProps: ContextMenu or Shift+F10 opens the row menu and the
|
|
6166
|
+
// row itself is reachable with Tab. Kept local (not the official helper)
|
|
6167
|
+
// so the SAME openCtx path still owns the target payload and the fallback
|
|
6168
|
+
// to the local menu on hosts without the service.
|
|
6169
|
+
const menuTriggerProps = (onContextMenu) => typeof onContextMenu !== 'function' ? {} : {
|
|
6170
|
+
tabIndex: 0,
|
|
6171
|
+
onKeyDown: (event) => {
|
|
6172
|
+
if (!event || event.repeat || event.isComposing || event.ctrlKey || event.altKey || event.metaKey) return
|
|
6173
|
+
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return
|
|
6174
|
+
event.preventDefault()
|
|
6175
|
+
event.stopPropagation()
|
|
6176
|
+
try { event.currentTarget.focus() } catch { /* detached row */ }
|
|
6177
|
+
onContextMenu(event)
|
|
6178
|
+
},
|
|
6179
|
+
}
|
|
6180
|
+
|
|
6128
6181
|
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dropSlot, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
|
|
6129
6182
|
const total = countWorkspaces(node)
|
|
6130
6183
|
const iconEl = iconOf(iconMode, expanded)
|
|
@@ -6149,6 +6202,7 @@ window.__ModuleLoader__.load({
|
|
|
6149
6202
|
role: 'treeitem',
|
|
6150
6203
|
'aria-expanded': expanded,
|
|
6151
6204
|
...(dragEvents || {}),
|
|
6205
|
+
...menuTriggerProps(onContextMenu),
|
|
6152
6206
|
},
|
|
6153
6207
|
// Official projectRow duality (Rows.module.css): the chevron hides
|
|
6154
6208
|
// until hover, swapping in for the folder glyph; the arrow spins
|
|
@@ -6179,6 +6233,7 @@ window.__ModuleLoader__.load({
|
|
|
6179
6233
|
role: 'treeitem',
|
|
6180
6234
|
'aria-expanded': sessionsOpen,
|
|
6181
6235
|
...(dragEvents || {}),
|
|
6236
|
+
...menuTriggerProps(onContextMenu),
|
|
6182
6237
|
},
|
|
6183
6238
|
// Official projectRow duality (Rows.module.css): the chevron hides
|
|
6184
6239
|
// until hover, swapping in for the folder glyph; the arrow spins
|
|
@@ -6429,6 +6484,7 @@ window.__ModuleLoader__.load({
|
|
|
6429
6484
|
role: 'treeitem',
|
|
6430
6485
|
'aria-description': node.archived ? archivedTitle || undefined : undefined,
|
|
6431
6486
|
...(dragEvents || {}),
|
|
6487
|
+
...menuTriggerProps(onContextMenu),
|
|
6432
6488
|
},
|
|
6433
6489
|
E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
|
|
6434
6490
|
status && typeof ui.StateDot === 'function'
|
|
@@ -6486,6 +6542,7 @@ window.__ModuleLoader__.load({
|
|
|
6486
6542
|
role: 'treeitem',
|
|
6487
6543
|
'aria-expanded': expanded,
|
|
6488
6544
|
...(dragEvents || {}),
|
|
6545
|
+
...menuTriggerProps(onContextMenu),
|
|
6489
6546
|
},
|
|
6490
6547
|
// Session groups are a plugin concept: their chevron stays visible
|
|
6491
6548
|
// (only the official container rows hide it until hover).
|
|
@@ -7666,6 +7723,7 @@ window.__ModuleLoader__.load({
|
|
|
7666
7723
|
shippedBrowserFace, scheduleSeatReader, seatEntriesReader,
|
|
7667
7724
|
officialT,
|
|
7668
7725
|
presetFaceOf,
|
|
7726
|
+
contextMenuFaceOf, setMenuBridge,
|
|
7669
7727
|
t,
|
|
7670
7728
|
} = props
|
|
7671
7729
|
// Issue #13: local reads and writes go through OUR store instance, never
|
|
@@ -7684,6 +7742,10 @@ window.__ModuleLoader__.load({
|
|
|
7684
7742
|
// Every consumer below keeps reading this local, so a late-arriving
|
|
7685
7743
|
// agent-preset service is picked up by the next render's menu build.
|
|
7686
7744
|
const presetFace = typeof presetFaceOf === 'function' ? presetFaceOf() : null
|
|
7745
|
+
// Official menu integration (alpha.3): the active target drives the
|
|
7746
|
+
// workspace row's open state, while the bridge carries the live bw
|
|
7747
|
+
// extras into the apply-level contribution.
|
|
7748
|
+
const activeContextMenu = useActiveContextMenu(contextMenuFaceOf)
|
|
7687
7749
|
|
|
7688
7750
|
if (typeof useWorkspaces !== 'function' || typeof useSessions !== 'function') {
|
|
7689
7751
|
console.error('[dsh-better-workspace] standard snapshot hooks missing; browser renders nothing')
|
|
@@ -8230,6 +8292,14 @@ window.__ModuleLoader__.load({
|
|
|
8230
8292
|
stable.delete(id) // quoted already, or the LLM name arrived slash-free: done
|
|
8231
8293
|
continue
|
|
8232
8294
|
}
|
|
8295
|
+
// v0.37.3: a path-shaped title is already ONE row at render time
|
|
8296
|
+
// (titleQuotesPath). Renaming it here would append a USER title and
|
|
8297
|
+
// supersede an asynchronous LLM name that has not landed yet, so the
|
|
8298
|
+
// fallback would stay pinned as a raw path. Leave it to the host.
|
|
8299
|
+
if (titleQuotesPath(text)) {
|
|
8300
|
+
stable.delete(id)
|
|
8301
|
+
continue
|
|
8302
|
+
}
|
|
8233
8303
|
const prev = stable.get(id)
|
|
8234
8304
|
if (prev && prev.title === text) {
|
|
8235
8305
|
if (now - prev.since >= TITLE_STABLE_MS) {
|
|
@@ -8513,9 +8583,53 @@ window.__ModuleLoader__.load({
|
|
|
8513
8583
|
const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
|
|
8514
8584
|
const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
|
|
8515
8585
|
|
|
8586
|
+
// Keyboard triggers carry clientX/clientY = 0. Mirror the shared menu's
|
|
8587
|
+
// own trigger: (0,0) anchors the popup at the focused row's bottom-left
|
|
8588
|
+
// instead of the viewport corner. Pointer events pass their point
|
|
8589
|
+
// through untouched.
|
|
8590
|
+
const menuPointOf = (e) => {
|
|
8591
|
+
const x = e && Number.isFinite(e.clientX) ? e.clientX : 0
|
|
8592
|
+
const y = e && Number.isFinite(e.clientY) ? e.clientY : 0
|
|
8593
|
+
if (x !== 0 || y !== 0) return { x, y }
|
|
8594
|
+
const target = e && e.currentTarget
|
|
8595
|
+
const rect = target && typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : null
|
|
8596
|
+
return rect ? { x: rect.left, y: rect.bottom } : { x, y }
|
|
8597
|
+
}
|
|
8516
8598
|
const openCtx = (kind, payload, e) => {
|
|
8517
8599
|
if (e) e.preventDefault()
|
|
8518
|
-
|
|
8600
|
+
const point = menuPointOf(e)
|
|
8601
|
+
// dsh 0.2.2-alpha.3: session and workspace are official target
|
|
8602
|
+
// families, so the shared popup owns their entries (official ones
|
|
8603
|
+
// plus our order-700 extras). Folder/session-group rows are virtual
|
|
8604
|
+
// objects with no official family and keep the local menu. Hosts
|
|
8605
|
+
// without the service keep the local menu for everything.
|
|
8606
|
+
if (e && (kind === 'session' || kind === 'workspace')) {
|
|
8607
|
+
const face = typeof contextMenuFaceOf === 'function' ? contextMenuFaceOf() : null
|
|
8608
|
+
if (face && typeof face.open === 'function') {
|
|
8609
|
+
try {
|
|
8610
|
+
if (kind === 'session') {
|
|
8611
|
+
face.open({
|
|
8612
|
+
kind: 'session',
|
|
8613
|
+
value: { sessionId: payload.id, displayTitle: String(payload.title || '') },
|
|
8614
|
+
}, point)
|
|
8615
|
+
} else {
|
|
8616
|
+
face.open({
|
|
8617
|
+
kind: 'workspace',
|
|
8618
|
+
value: {
|
|
8619
|
+
workspaceId: payload.workspaceId,
|
|
8620
|
+
path: String(payload.path || ''),
|
|
8621
|
+
displayTitle: String(payload.title || payload.leaf || ''),
|
|
8622
|
+
},
|
|
8623
|
+
}, point)
|
|
8624
|
+
}
|
|
8625
|
+
setCtx(null)
|
|
8626
|
+
return
|
|
8627
|
+
} catch (error) {
|
|
8628
|
+
console.warn('[dsh-better-workspace] official context menu open failed; using local menu', error)
|
|
8629
|
+
}
|
|
8630
|
+
}
|
|
8631
|
+
}
|
|
8632
|
+
setCtx({ kind, payload, ...point })
|
|
8519
8633
|
}
|
|
8520
8634
|
|
|
8521
8635
|
// Session-action surface (v0.20.0, official mirror): the right-click
|
|
@@ -10225,8 +10339,13 @@ window.__ModuleLoader__.load({
|
|
|
10225
10339
|
// The in-row ellipsis is retired; the ctx surface stays the
|
|
10226
10340
|
// right-click path. While the menu is open the row still pins
|
|
10227
10341
|
// (hover fill + visible actions), matching the official menuOpen
|
|
10228
|
-
// state
|
|
10229
|
-
|
|
10342
|
+
// state — the shared popup reports its captured target through
|
|
10343
|
+
// contextMenus.active, while older hosts keep the local ctx.
|
|
10344
|
+
menuOpen: !!(
|
|
10345
|
+
(ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId)
|
|
10346
|
+
|| (activeContextMenu && activeContextMenu.kind === 'workspace'
|
|
10347
|
+
&& activeContextMenu.value && activeContextMenu.value.workspaceId === workspace.workspaceId)
|
|
10348
|
+
),
|
|
10230
10349
|
hoverBody: workspaceHoverBody,
|
|
10231
10350
|
dropHalf: wsDropHalf(workspace.workspaceId),
|
|
10232
10351
|
dropInto: wsRootDropActive(workspace.workspaceId) || wsGroupIntoActive(workspace.workspaceId),
|
|
@@ -10489,6 +10608,13 @@ window.__ModuleLoader__.load({
|
|
|
10489
10608
|
customizeEntry,
|
|
10490
10609
|
]
|
|
10491
10610
|
}
|
|
10611
|
+
// One group-LEAVING implementation for the local menu and the official
|
|
10612
|
+
// contribution: retitle to the leaf and let the user-touch wrapper pin it.
|
|
10613
|
+
const moveSessionOut = (payload) => {
|
|
10614
|
+
const title = String(payload.title || '')
|
|
10615
|
+
const leaf = leafOf(title)
|
|
10616
|
+
if (leaf !== title) Promise.resolve().then(() => renameByUser(payload.id, leaf)).catch(fail)
|
|
10617
|
+
}
|
|
10492
10618
|
const pickSessionAction = (payload, id) => {
|
|
10493
10619
|
if (id === 'rename') { setDialog({ kind: 'sess-rename', session: payload }); return true }
|
|
10494
10620
|
if (id === 'fork') { forkSession(payload.id); return true }
|
|
@@ -10528,13 +10654,48 @@ window.__ModuleLoader__.load({
|
|
|
10528
10654
|
}
|
|
10529
10655
|
// Plain retitle back to the leaf: the row leaves its group and
|
|
10530
10656
|
// returns to the top level of its workspace (v0.18.0).
|
|
10531
|
-
if (id === 'move-out') {
|
|
10532
|
-
const leaf = leafOf(String(payload.title || ''))
|
|
10533
|
-
if (leaf !== payload.title) Promise.resolve().then(() => renameByUser(payload.id, leaf)).catch(fail)
|
|
10534
|
-
return true
|
|
10535
|
-
}
|
|
10657
|
+
if (id === 'move-out') { moveSessionOut(payload); return true }
|
|
10536
10658
|
return false
|
|
10537
10659
|
}
|
|
10660
|
+
// Shared workspace-preset flow: the local menu and the official
|
|
10661
|
+
// contribution both land here, so the blank-session rules cannot drift.
|
|
10662
|
+
const openWorkspacePresetDialog = (payload) => {
|
|
10663
|
+
if (!presetFace) return
|
|
10664
|
+
// Freeze the target NOW (the dialog outlives the menu ctx): the
|
|
10665
|
+
// blank lookup mirrors the official createInWorkspace rule — a
|
|
10666
|
+
// standing blank IS the workspace default (its preset projection
|
|
10667
|
+
// is what the hero chip shows). With NO standing blank the edit
|
|
10668
|
+
// still applies: ClientSessions.create prepares one WITH the
|
|
10669
|
+
// picked preset (the same channel as the official new-session
|
|
10670
|
+
// button), which makes it the workspace default. Creating a
|
|
10671
|
+
// session here is deliberate, not a side effect — the host has
|
|
10672
|
+
// no other per-workspace default store (agent-preset-registry
|
|
10673
|
+
// selectedDefault is GLOBAL; the standing blank IS the carrier).
|
|
10674
|
+
const raw = (storedItems || []).find((item) => item.workspaceId === payload.workspaceId)
|
|
10675
|
+
const blankIds = raw && Array.isArray(raw.sessionIds) ? raw.sessionIds : []
|
|
10676
|
+
const blankId = raw && raw.path
|
|
10677
|
+
? (list.ids || []).find((sid) => {
|
|
10678
|
+
const summary = list.byId[sid]
|
|
10679
|
+
return summary && summary.blank === true && summary.cwd === raw.path
|
|
10680
|
+
&& blankIds.indexOf(sid) !== -1 && !archivedSet.has(sid)
|
|
10681
|
+
})
|
|
10682
|
+
: undefined
|
|
10683
|
+
const blankSummary = blankId !== undefined ? list.byId[blankId] : undefined
|
|
10684
|
+
const projection = blankSummary ? blankSummary.projectionValues : undefined
|
|
10685
|
+
setDialog({
|
|
10686
|
+
kind: 'ws-preset',
|
|
10687
|
+
hasBlank: blankId !== undefined,
|
|
10688
|
+
applyPreset: blankId !== undefined
|
|
10689
|
+
? (presetId) => presetFace.select(blankId, presetId)
|
|
10690
|
+
// Reuse declined / no draft to reuse: create WITH the preset.
|
|
10691
|
+
// Shape matches select's { ok } contract; a SessionCreateError
|
|
10692
|
+
// lands in the dialog as an inline refusal line.
|
|
10693
|
+
: (presetId) => Promise.resolve()
|
|
10694
|
+
.then(() => sessions.create({ cwd: raw.path, agentPreset: presetId }))
|
|
10695
|
+
.then(() => ({ ok: true }), (reason) => ({ ok: false, refusal: reason instanceof Error ? reason.message : String(reason || 'session create failed') })),
|
|
10696
|
+
current: projection && typeof projection.agentPreset === 'string' ? projection.agentPreset : undefined,
|
|
10697
|
+
})
|
|
10698
|
+
}
|
|
10538
10699
|
const menuEntries = () => {
|
|
10539
10700
|
if (ctx === null) return []
|
|
10540
10701
|
const customizeEntry = { id: 'customize', label: t('custom.title'), icon: icon('IconPersonalizationOutline16', 16) }
|
|
@@ -10616,42 +10777,7 @@ window.__ModuleLoader__.load({
|
|
|
10616
10777
|
else if (kind === 'sgroup' && id === 'delete-sgroup') setDialog({ kind: 'sgroup-delete', target: payload })
|
|
10617
10778
|
else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
|
|
10618
10779
|
else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
|
|
10619
|
-
else if (kind === 'workspace' && id === 'ws-preset' && presetFace)
|
|
10620
|
-
// Freeze the target NOW (the dialog outlives the menu ctx): the
|
|
10621
|
-
// blank lookup mirrors the official createInWorkspace rule — a
|
|
10622
|
-
// standing blank IS the workspace default (its preset projection
|
|
10623
|
-
// is what the hero chip shows). With NO standing blank the edit
|
|
10624
|
-
// still applies: ClientSessions.create prepares one WITH the
|
|
10625
|
-
// picked preset (the same channel as the official new-session
|
|
10626
|
-
// button), which makes it the workspace default. Creating a
|
|
10627
|
-
// session here is deliberate, not a side effect — the host has
|
|
10628
|
-
// no other per-workspace default store (agent-preset-registry
|
|
10629
|
-
// selectedDefault is GLOBAL; the standing blank IS the carrier).
|
|
10630
|
-
const raw = (storedItems || []).find((item) => item.workspaceId === payload.workspaceId)
|
|
10631
|
-
const blankIds = raw && Array.isArray(raw.sessionIds) ? raw.sessionIds : []
|
|
10632
|
-
const blankId = raw && raw.path
|
|
10633
|
-
? (list.ids || []).find((sid) => {
|
|
10634
|
-
const summary = list.byId[sid]
|
|
10635
|
-
return summary && summary.blank === true && summary.cwd === raw.path
|
|
10636
|
-
&& blankIds.indexOf(sid) !== -1 && !archivedSet.has(sid)
|
|
10637
|
-
})
|
|
10638
|
-
: undefined
|
|
10639
|
-
const blankSummary = blankId !== undefined ? list.byId[blankId] : undefined
|
|
10640
|
-
const projection = blankSummary ? blankSummary.projectionValues : undefined
|
|
10641
|
-
setDialog({
|
|
10642
|
-
kind: 'ws-preset',
|
|
10643
|
-
hasBlank: blankId !== undefined,
|
|
10644
|
-
applyPreset: blankId !== undefined
|
|
10645
|
-
? (presetId) => presetFace.select(blankId, presetId)
|
|
10646
|
-
// Reuse declined / no draft to reuse: create WITH the preset.
|
|
10647
|
-
// Shape matches select's { ok } contract; a SessionCreateError
|
|
10648
|
-
// lands in the dialog as an inline refusal line.
|
|
10649
|
-
: (presetId) => Promise.resolve()
|
|
10650
|
-
.then(() => sessions.create({ cwd: raw.path, agentPreset: presetId }))
|
|
10651
|
-
.then(() => ({ ok: true }), (reason) => ({ ok: false, refusal: reason instanceof Error ? reason.message : String(reason || 'session create failed') })),
|
|
10652
|
-
current: projection && typeof projection.agentPreset === 'string' ? projection.agentPreset : undefined,
|
|
10653
|
-
})
|
|
10654
|
-
}
|
|
10780
|
+
else if (kind === 'workspace' && id === 'ws-preset' && presetFace) openWorkspacePresetDialog(payload)
|
|
10655
10781
|
else if (kind === 'sgroup' && id === 'rename-sgroup') setDialog({ kind: 'sgroup-rename', target: payload })
|
|
10656
10782
|
else if (kind === 'session') {
|
|
10657
10783
|
// The shared dispatcher owns every session verb (see
|
|
@@ -10660,6 +10786,40 @@ window.__ModuleLoader__.load({
|
|
|
10660
10786
|
}
|
|
10661
10787
|
}
|
|
10662
10788
|
|
|
10789
|
+
// The official contribution runs outside this component, so it gets a
|
|
10790
|
+
// live bridge to the SAME action implementations (dialogs included).
|
|
10791
|
+
// The effect republishes after every render: slash switches, the preset
|
|
10792
|
+
// face and the current session inventory all feed availability.
|
|
10793
|
+
const menuBridgeValue = {
|
|
10794
|
+
slashes: { session: sessionSlash, workspace: workspaceSlash },
|
|
10795
|
+
hasPreset: () => !!presetFace,
|
|
10796
|
+
groupPrefix: (title) => groupPrefixOf(title),
|
|
10797
|
+
label: (key) => t(key),
|
|
10798
|
+
customize: (kind, payload) => {
|
|
10799
|
+
const name = kind === 'workspace' ? (payload.title || payload.leaf) : payload.title
|
|
10800
|
+
setCustomize({ kind, entryKey: keyOf(kind, payload), name })
|
|
10801
|
+
},
|
|
10802
|
+
moveGroup: (kind, payload) => {
|
|
10803
|
+
const title = kind === 'workspace' ? (payload.title || payload.leaf) : payload.title
|
|
10804
|
+
const target = kind === 'workspace'
|
|
10805
|
+
? { kind: 'workspace', id: payload.workspaceId, title }
|
|
10806
|
+
: { kind: 'session', id: payload.id, title }
|
|
10807
|
+
setDialog({
|
|
10808
|
+
kind: 'group-move',
|
|
10809
|
+
target,
|
|
10810
|
+
initial: groupPrefixOf(target.title),
|
|
10811
|
+
groupPaths: collectGroupPaths(kind),
|
|
10812
|
+
})
|
|
10813
|
+
},
|
|
10814
|
+
moveOut: moveSessionOut,
|
|
10815
|
+
workspacePreset: (payload) => openWorkspacePresetDialog(payload),
|
|
10816
|
+
}
|
|
10817
|
+
React.useEffect(() => {
|
|
10818
|
+
if (typeof setMenuBridge !== 'function') return undefined
|
|
10819
|
+
setMenuBridge(menuBridgeValue)
|
|
10820
|
+
return () => setMenuBridge(null)
|
|
10821
|
+
})
|
|
10822
|
+
|
|
10663
10823
|
/* --------------------------- dialogs --------------------------- */
|
|
10664
10824
|
// TextDialog / ConfirmDialog are module-level components: a per-render
|
|
10665
10825
|
// inline definition would remount on every parent tick and drop input.
|
|
@@ -11153,6 +11313,17 @@ window.__ModuleLoader__.load({
|
|
|
11153
11313
|
// the handle before any flow can open.
|
|
11154
11314
|
pickerState.api = uiWorkspace
|
|
11155
11315
|
|
|
11316
|
+
// dsh 0.2.2-alpha.3 context menus. The service is acquired SOFTLY for
|
|
11317
|
+
// the same reason as remote.agentPresets: a hard inject would pend the
|
|
11318
|
+
// whole bundle forever on older hosts. The bridge is written by the
|
|
11319
|
+
// browser seat through browserInjected (a stable getter), so the
|
|
11320
|
+
// contribution resolves against the same dialogs and switches the
|
|
11321
|
+
// local menu uses.
|
|
11322
|
+
let contextMenus = null
|
|
11323
|
+
let menuBridge = null
|
|
11324
|
+
let menusRegistered = false
|
|
11325
|
+
const setMenuBridge = (bridge) => { menuBridge = bridge }
|
|
11326
|
+
|
|
11156
11327
|
// The OFFICIAL browser's own dictionary (NS 'workspace'), bound so the
|
|
11157
11328
|
// context menu COPY follows upstream wording automatically — the menu
|
|
11158
11329
|
// mirrors the official row menus item-for-item (Rows.tsx), and this
|
|
@@ -11255,6 +11426,126 @@ window.__ModuleLoader__.load({
|
|
|
11255
11426
|
}
|
|
11256
11427
|
}
|
|
11257
11428
|
|
|
11429
|
+
// The official shared menu owns session/workspace rows from alpha.3 on.
|
|
11430
|
+
// The built-in contributions cover pin/rename/fork/copy/archive and
|
|
11431
|
+
// workspace rename/delete; these two bring the bw-only verbs (name
|
|
11432
|
+
// groups, Customize appearance, the workspace default preset) after
|
|
11433
|
+
// them. The first extra carries the separator, so the popup keeps the
|
|
11434
|
+
// same grouping the retired local menu had.
|
|
11435
|
+
const withMenuSeparator = (actions) => actions.map((action, index) => index === 0
|
|
11436
|
+
? Object.assign({}, action, { separatorBefore: true })
|
|
11437
|
+
: action)
|
|
11438
|
+
const registerContextMenus = (menus) => {
|
|
11439
|
+
if (!menus || menusRegistered) return
|
|
11440
|
+
if (typeof menus.register !== 'function' || typeof menus.open !== 'function') return
|
|
11441
|
+
menusRegistered = true
|
|
11442
|
+
const sessionExtras = {
|
|
11443
|
+
id: 'bw-session-extras',
|
|
11444
|
+
target: 'session',
|
|
11445
|
+
order: 700,
|
|
11446
|
+
resolve: (target) => {
|
|
11447
|
+
const bridge = menuBridge
|
|
11448
|
+
if (!bridge) return []
|
|
11449
|
+
const title = String((target && target.displayTitle) || '')
|
|
11450
|
+
const actions = []
|
|
11451
|
+
if (bridge.slashes && bridge.slashes.session) {
|
|
11452
|
+
actions.push({
|
|
11453
|
+
id: 'bw-move-group',
|
|
11454
|
+
label: bridge.label('menu.moveToGroup'),
|
|
11455
|
+
run: () => bridge.moveGroup('session', { id: target.sessionId, title }),
|
|
11456
|
+
})
|
|
11457
|
+
}
|
|
11458
|
+
if (bridge.groupPrefix && bridge.groupPrefix(title) !== '') {
|
|
11459
|
+
actions.push({
|
|
11460
|
+
id: 'bw-move-out',
|
|
11461
|
+
label: bridge.label('menu.moveOutGroup'),
|
|
11462
|
+
run: () => bridge.moveOut({ id: target.sessionId, title }),
|
|
11463
|
+
})
|
|
11464
|
+
}
|
|
11465
|
+
actions.push({
|
|
11466
|
+
id: 'bw-customize',
|
|
11467
|
+
label: bridge.label('custom.title'),
|
|
11468
|
+
run: () => bridge.customize('session', { id: target.sessionId, title }),
|
|
11469
|
+
})
|
|
11470
|
+
return withMenuSeparator(actions)
|
|
11471
|
+
},
|
|
11472
|
+
}
|
|
11473
|
+
const workspaceExtras = {
|
|
11474
|
+
id: 'bw-workspace-extras',
|
|
11475
|
+
target: 'workspace',
|
|
11476
|
+
order: 700,
|
|
11477
|
+
resolve: (target) => {
|
|
11478
|
+
const bridge = menuBridge
|
|
11479
|
+
if (!bridge) return []
|
|
11480
|
+
const title = String((target && target.displayTitle) || '')
|
|
11481
|
+
const payload = { workspaceId: target.workspaceId, path: target.path, title, leaf: title }
|
|
11482
|
+
const actions = []
|
|
11483
|
+
if (bridge.slashes && bridge.slashes.workspace) {
|
|
11484
|
+
actions.push({
|
|
11485
|
+
id: 'bw-move-group',
|
|
11486
|
+
label: bridge.label('menu.moveToGroup'),
|
|
11487
|
+
run: () => bridge.moveGroup('workspace', payload),
|
|
11488
|
+
})
|
|
11489
|
+
}
|
|
11490
|
+
actions.push({
|
|
11491
|
+
id: 'bw-customize',
|
|
11492
|
+
label: bridge.label('custom.title'),
|
|
11493
|
+
run: () => bridge.customize('workspace', payload),
|
|
11494
|
+
})
|
|
11495
|
+
if (bridge.hasPreset && bridge.hasPreset()) {
|
|
11496
|
+
actions.push({
|
|
11497
|
+
id: 'bw-ws-preset',
|
|
11498
|
+
label: bridge.label('menu.workspacePreset'),
|
|
11499
|
+
run: () => bridge.workspacePreset(payload),
|
|
11500
|
+
})
|
|
11501
|
+
}
|
|
11502
|
+
return withMenuSeparator(actions)
|
|
11503
|
+
},
|
|
11504
|
+
}
|
|
11505
|
+
try {
|
|
11506
|
+
ctx.effect(() => {
|
|
11507
|
+
const disposers = []
|
|
11508
|
+
try { disposers.push(menus.register(sessionExtras)) } catch (registerError) {
|
|
11509
|
+
console.warn('[dsh-better-workspace] session context menu registration failed', registerError)
|
|
11510
|
+
}
|
|
11511
|
+
try { disposers.push(menus.register(workspaceExtras)) } catch (registerError) {
|
|
11512
|
+
console.warn('[dsh-better-workspace] workspace context menu registration failed', registerError)
|
|
11513
|
+
}
|
|
11514
|
+
return () => {
|
|
11515
|
+
for (const dispose of disposers) {
|
|
11516
|
+
try { dispose() } catch { /* already withdrawn */ }
|
|
11517
|
+
}
|
|
11518
|
+
}
|
|
11519
|
+
}, 'better-workspace: context menu contributions')
|
|
11520
|
+
} catch (effectError) {
|
|
11521
|
+
console.warn('[dsh-better-workspace] context menu effect failed', effectError)
|
|
11522
|
+
}
|
|
11523
|
+
}
|
|
11524
|
+
const adoptContextMenus = (candidate) => {
|
|
11525
|
+
if (!candidate || typeof candidate.register !== 'function' || typeof candidate.open !== 'function') return
|
|
11526
|
+
const changed = contextMenus !== candidate
|
|
11527
|
+
contextMenus = candidate
|
|
11528
|
+
registerContextMenus(candidate)
|
|
11529
|
+
if (changed) notifyMenuFace()
|
|
11530
|
+
}
|
|
11531
|
+
try { adoptContextMenus(ctx.get('contextMenus')) } catch (contextMenuProbeError) {
|
|
11532
|
+
console.warn('[dsh-better-workspace] context menu probe failed', contextMenuProbeError)
|
|
11533
|
+
}
|
|
11534
|
+
if (!contextMenus && ctx && typeof ctx.inject === 'function') {
|
|
11535
|
+
// Late arrival: the shared menu service mounts after this bundle on
|
|
11536
|
+
// some boots. A soft child fiber waits for it; old hosts simply keep
|
|
11537
|
+
// the fiber pending and the local menu stays in charge.
|
|
11538
|
+
try {
|
|
11539
|
+
ctx.inject(['contextMenus'], (fctx) => {
|
|
11540
|
+
try { adoptContextMenus(fctx && fctx.contextMenus) } catch (lateError) {
|
|
11541
|
+
console.warn('[dsh-better-workspace] context menu late face failed', lateError)
|
|
11542
|
+
}
|
|
11543
|
+
})
|
|
11544
|
+
} catch (injectError) {
|
|
11545
|
+
console.warn('[dsh-better-workspace] context menu soft inject failed', injectError)
|
|
11546
|
+
}
|
|
11547
|
+
}
|
|
11548
|
+
|
|
11258
11549
|
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
11259
11550
|
ctx.effect(() => {
|
|
11260
11551
|
try {
|
|
@@ -11338,6 +11629,12 @@ window.__ModuleLoader__.load({
|
|
|
11338
11629
|
// closure live, so every render and every menu build re-asks; hosts
|
|
11339
11630
|
// without the service keep returning null, which simply hides the row.
|
|
11340
11631
|
presetFaceOf: () => presetFace,
|
|
11632
|
+
// dsh 0.2.2-alpha.3: the shared context-menu service (getter for the
|
|
11633
|
+
// same late-arrival reason as presetFaceOf; null on older hosts, which
|
|
11634
|
+
// keep the local menu) and the live action bridge its contribution
|
|
11635
|
+
// reads. The getter is what openCtx probes on every right-click.
|
|
11636
|
+
contextMenuFaceOf: () => contextMenus,
|
|
11637
|
+
setMenuBridge,
|
|
11341
11638
|
// Issue #13: the PRIVATE store face — a stable getter like presetFaceOf
|
|
11342
11639
|
// (the slot renderer caches inject results; keep the same discipline
|
|
11343
11640
|
// even though this face never changes).
|