dsh-url-router 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +35 -1
- package/README.md +25 -2
- package/lib/client.js +495 -13
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
本仓是一个独立的 DeepSeek Harness Web GUI 插件仓(**只有浏览器半区**)。
|
|
4
4
|
|
|
5
|
+
## 社区插件索引登记(重要)
|
|
6
|
+
|
|
7
|
+
- 索引仓库:<https://github.com/zhu1090093659/dsh-community-plugins> ✓(`community.json` 是 DSH Workshop 商店与 dsh-market.com 清单的**唯一数据源** ✓)。
|
|
8
|
+
- 本插件条目 `dsh-url-router`(`ui/panel`)**已登记** ✓(PR <https://github.com/zhu1090093659/dsh-community-plugins/pull/30> 已合并 ✓,上游 `community.json` 现为 136 条 ✓)。
|
|
9
|
+
- 我对那个仓库**只有读权限** ✓ → 流程是 **fork(`DDDMUC/dsh-community-plugins` ✓)→ 对齐上游 main → 追加条目 → 跑门禁 → 强推自己的 fork → 开 PR** ✓。
|
|
10
|
+
- 那个仓库的规矩(与我们自己的不同 ✗):**`community.json` 只追加、一个条目一个提交** ✓;必填 `id/name/nameEn/author/repo`,但发布面要求连 `description/descriptionEn/npm/category/subcategory` 一起填 ✓;`repo` 必须匹配 `^https://[A-Za-z0-9._~/-]+$` ✓(市场会把它变成 shell 命令 ✗,禁空白与元字符 ✓);`npm` 必须匹配 `^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$` ✓;`category` 限 `ui/agent/tools/knowledge/integration/security/utility` ✓,`subcategory` 必须属于该 category 的白名单 ✓(`ui` 只接受 `terminal/chat/render/panel` ✓)。
|
|
11
|
+
- 提交前必跑:`node scripts/community-index.cjs --check` ✓ 与 `node --test scripts/community-index.test.mjs` ✓。
|
|
12
|
+
- ** Conventional Commits,且那个仓库全文禁 emoji** ✓(连对勾叉号都别用 ✗ —— 我们自己仓库的 ✓/✗ 风格不能带过去 ✗)。
|
|
13
|
+
- **发新版后要做的事** ✓:`description` 是给所有用户看的商店文案 ✓,**功能变了就得同步条目** ✓(上游有先例:`fix(community-plugins): sync …` 这类 PR ✓)—— 发 0.4.x 时回来核对一遍描述与当前行为 ✓。
|
|
14
|
+
|
|
5
15
|
## 形态约束
|
|
6
16
|
|
|
7
17
|
- **手写 JS,直接进 `lib/`**:没有构建步骤,`lib/index.js`(宿主半区,空操作)与 `lib/client.js`(浏览器半区,交付产物)既是源也是产物。
|
|
@@ -20,6 +30,12 @@
|
|
|
20
30
|
- **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:<uuid>"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/<id>` ✓(不猜 ✓)。
|
|
21
31
|
- **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。
|
|
22
32
|
- **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
|
|
33
|
+
- **`data-dsh-surface` 从来不是官方标记** ✓(2026-10-24 实测订正,**上一轮"官方把设置标记改名了"的结论作废** ✗):官方设置浮层根元素是 `role="dialog" aria-modal="true" data-shortcut-modal="settings"` ✓(`dsh-client-ui-settings-general/lib/client.js`,`dsh-client-ui-shortcuts` 与 `dsh-web-frontend/dist` 同名 ✓),里面 `<nav>` 的按钮仍是 `aria-current="true"` ✓。那个老名字是**第三方「皮肤中心」`@linxin666/dsh-client-ui-skin-center` 用 compat 适配器给官方 DOM 补打的** ✓(它自己的契约文档 `contracts/semantic-attrs-v1.md` 写明了 owner 是 skin-center、"非永久公共契约"、上游主题缝落地后进入删除评估 ✓)。
|
|
34
|
+
⇒ 症状按 profile 分裂,**不是"两边一样"** ✓:装了皮肤中心的 web profile 里地址栏一直正常反映设置 ✓(我 3080 实测:开浮层 → `#/settings/账号与余额` ✓,两个标记同时在场 ✓);桌面 home 没装它 ✗ → 只有那边"地址栏不反映设置"。**粘 `#/settings` 开不出来**同理 ✓(驱动也读同一个标记)。
|
|
35
|
+
⇒ 现在的做法:`settingsOverlayOf()` **官方标记优先、皮肤中心标记兜底** ✓。**别把顺序改回去** ✗,也**别拿兜底那条当证据** —— 用户哪天关掉皮肤中心它就没了 ✓,官方那条永远在 ✓。
|
|
36
|
+
**教训**:`data-dsh-*` 这类语义属性**先问是谁打的**再决定能不能当契约 ✗(扫官方包 0 命中是真的,错在当年把它当成官方契约写进路由)。判断依据是**真机 DOM** ✓,不是 grep ✓。
|
|
37
|
+
- **会话链接必须先把挡在前面的主面板让开** ✓(2026-10-24 修):主面板选中时 `canonical` 就是面板 ✓,直接 `openSession` 会让地址栏立刻被写回面板路由 ✗(桌面版地址栏实测:面板 → 对话「没反应」✓)。驱动会话 / 工作区 / 轮次 / 行这些路由前,先 `host.beginNavigation()` + `host.selectPanel(null)` ✓,落地后由 `canonical` 归位 ✓。用例:`a conversation link takes the main column back from an open panel` ✓。
|
|
38
|
+
- **设置浮层挡的是一整列,不止面板** ✓(2026-10-24 桌面实测):浮层在屏时 `canonical` 读到的就是 `#/settings/<分区>` ✓,所以**任何**非设置链接在它后面都落不了地,8 秒后按"视图没动"过期 ✗ —— 会话其实已经在浮层**背后**打开了 ✓,用户只看到"打不开"✗(截图:浮层停在「通用设置」✓,粘 `#/workspace/默认工作区/session/…` ✓,地址栏仍是 `#/settings/通用设置` ✓)。做法:`host.closeSettings()` 派发一次 document 级 `keydown` `Escape` ✓ —— 官方模态层注册的就是这个监听(bundle 实测 `document.addEventListener('keydown', h, true)` 且 `key === 'Escape'` 时关最上层 ✓),而 `closeTopModal` 走的那个模态栈注册表是 bundle 私有的 ✗ 外部拿不到 ⇒ **合成 Escape 是浏览器半侧唯一可用的关闭路径** ✓,也正是用户按 Esc 走的那条 ✓。**一条链接只按一次** ✓(`overlayDismissed` 随新链接复位 ✓):每次 DOM 通知都派发会打扰正在操作页面的用户 ✓,代价只是"没关掉就得再粘一次" ✓。设置自己的链接**不关** ✓。用例:`a conversation link takes the settings overlay out of the way` ✓。
|
|
23
39
|
- **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。
|
|
24
40
|
- **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。
|
|
25
41
|
- 只用 `replaceState`;路径与查询串原样保留。
|
|
@@ -31,13 +47,20 @@
|
|
|
31
47
|
- 锚点 href 必须用本插件自己的编码函数 `routeHash('session', id)`,不要另写一套。
|
|
32
48
|
- **找不到 DOM / 行属性不匹配一律静默 no-op**,绝不改官方行、绝不抛错;锚点点击里的导航也要 `try/catch`(官方导航可能抛)。
|
|
33
49
|
- 行选择器或属性一旦改动,必须同步改 `test/client.test.js` 里的假 DOM 用例(假 DOM 已包含 `fakeDom`/`openWithDom`)。
|
|
50
|
+
- **侧栏扫描有预算(2026-10-18 起,别改回去)**:
|
|
51
|
+
- 观察者是**每帧一次** ✓:一帧内到达的多个 mutation 批次合并成一次 `scan()` ✓(`requestAnimationFrame` ✓;没有 rAF 的最小 DOM / 假 DOM 走**同步**扫描 ✗ 不排队 ✓,否则测试与无帧环境会丢链接 ✓)。
|
|
52
|
+
- 批次**先过滤** ✓:只有可能带来行的节点才触发扫描 ✓(元素自身或子树里有 `[data-row-key^="session:"]` / `[data-plugin-item]` / `[data-plugin-package]` / panel slot ✓);**纯文本批次直接跳过** ✓(流式回答、hover、工具输出整天在改 DOM ✗ 但它们永远不带行 ✓)。批次为空(拿不到记录:假 DOM、非标准 DOM)**按"要扫"处理** ✓ —— 宁可多扫一次也绝不漏行 ✓。
|
|
53
|
+
- 工作区归属**一趟遍历** ✓(`workspaceNamesOfRows()` 建 Map ✓),不是每行走一遍 `[data-row-key]` ✗(旧实现是 O(N²) ✓)。`workspaceNameOf(row)` 保留给单行调用(反映侧 ✓)。
|
|
54
|
+
- 已经挂着锚的行**跳过重挂** ✓,但判据是**行内 `querySelector` 找到锚** ✓,不是行上的 `data-dsh-url-router-host` ✗ —— 视图重渲染可能把锚点丢掉而属性还在 ✓,用属性判会**永久失去修复机会** ✓(有回归用例覆盖 ✓)。
|
|
55
|
+
- `panelRows()` 走 **`[data-slot="sidebar.panellist"]` 属性查询 + `closest('button')`** ✓,不再遍历全文档按钮再逐个查子树 ✗。
|
|
56
|
+
- 卸载时**取消排队的那一帧** ✓(`cancelAnimationFrame` ✓)。
|
|
34
57
|
|
|
35
58
|
- **面板内部页是多段的**:`#/panel/plugins/<包>/<组件>`(每段单独 `encodeURIComponent`,所以原生的 `/` 一定是层级分隔)。钩子分**两组**,不要以为一套通吃:**已安装组** `li[data-plugin-package]` → `[data-plugin-detail]` → `li[data-plugin-row="<kind>:<rowId>"]` → `[data-plugin-row-detail="<包>#<rowId>"]`;**官方组** `li[data-plugin-item="<id>"]` → `[data-plugin-item-detail="<id>"]`(实测:官方项页面**没有** `data-plugin-detail`)。驱动一个段位时先找包行、再找官方行。**钩子有两种拼写**(`#` 与 `:`),解析统一走 `splitRowHook()`,别自己 `split`。
|
|
36
59
|
- **任务看板走它自己的契约**(由用户单仓 `dsh-task-board` 发布):`#/panel/task-board/task/<任务id>` 与 `#/panel/task-board/new`。反映读 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`;驱动派发同名窗口事件;**表单优先于任务详情**。
|
|
37
60
|
- **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。
|
|
38
61
|
- **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。
|
|
39
62
|
- **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。
|
|
40
|
-
- **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `
|
|
63
|
+
- **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `settingsOverlayOf()` ✓(**官方 `data-shortcut-modal="settings"` 优先** ✓,见上条 ✓);驱动点 `[data-slot="sidebar.settings"]` 的入口 ✓(兜底按可访问名 ✓)。
|
|
41
64
|
- **设置分区的身份 = 显示名**(实测无稳定 id ✗:导航项只有 `aria-current="true"` ✓,无 `id`/`data-*`/`aria-controls` ✗,右侧内容区无 `data-*` ✗)。反映:在覆盖层里找 `aria-current="true"` **且 `closest('nav') !== null`** 的按钮 ✓,取它的标签文字 ✓;驱动:按标签(先精确、再忽略大小写)点 ✓。**换语言/改文案会让旧分区链接失效** ✗(重新点一次即写出新链接 ✓)。
|
|
42
65
|
- **设置分区里的页签同理,再深一层** ✓:`#/settings/<分区>/<页签>`。契约是标准 ARIA ✓:`[role="tab"][aria-selected="true"]` ✓(实测 `dsh-usage` 的用量/个人套餐/Token 银行就是 `role="tab"` ✓),身份同样是**标签文字** ✓;驱动=先点分区、再点标签匹配的页签 ✓(同一次调用里做完 ✓,因为分区一点开,页签就在同一个覆盖层里 ✓)。
|
|
43
66
|
- **`aria-selected` 也必须观察** ✓(与 `aria-current` 同理:切换页签只改它 ✓)。
|
|
@@ -57,6 +80,17 @@
|
|
|
57
80
|
- **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
|
|
58
81
|
- **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
|
|
59
82
|
|
|
83
|
+
## 桌面地址栏契约(2026-10-24 起,改之前先读)
|
|
84
|
+
|
|
85
|
+
- **形态**:Windows 桌面版的标题栏是**页面里画的** ✓ —— preload(`app.asar` 的 `lib/preload-app.cjs`)跑 `syncWindowsAppearance()`:给 `<html>` 打 `data-windows-titlebar`、设 `--dsh-windows-titlebar-height: 40px`,并用 shadow DOM 画菜单条(宿主 `[data-windows-menu]`,`position:fixed; top:0; left:var(--dsh-windows-menu-start,48px); z-index:1100; -webkit-app-region:no-drag`)。⇒ 那条 strip 是**页面空间** ✓,插件可以往里画 ✓。
|
|
86
|
+
- **门控**:只有 `documentElement.getAttribute('data-windows-titlebar') !== null` 时才画 ✓;Web 版与 macOS 上**一行都不执行** ✓("并进 url-router 但不影响其它平台"的全部机制就是这个 ✓)。
|
|
87
|
+
- **定位**:`left = 48 + 菜单宿主宽度 + 8` ✓(宽度用宿主 `getBoundingClientRect()` 量 ✓,**不要**读 shadow 内部 ✗);`right = TITLEBAR_CAPTION_WIDTH = 138px` 是 Windows 窗口按钮宽度常量 ✓(改缩放/换系统可能要调 ✓);高度一律走 CSS 变量 ✓,不要写死 40 ✓。
|
|
88
|
+
- **交互**:`-webkit-app-region: no-drag` 必须留 ✓ —— strip 本身是拖拽区 ✗,去掉它输入框点不动 ✗。
|
|
89
|
+
- **落地只写 hash** ✓:`window.location.hash = fragment` ✓,绝不自己调路由内部 ✗ —— 浏览器触发 `hashchange` ✓,落地就是"粘贴链接"那条已验证的路 ✓。
|
|
90
|
+
- **反映靠 feed** ✓:`reflect()` 写完地址栏后 `publishRoute(wantedHash)` ✓,桌面栏 `subscribeRoute(show)` ✓。**别改成监听 `hashchange`** ✗ —— 本插件用 `replaceState` 写地址栏 ✗、**不触发** hashchange ✗,改了就变成"框里永远是旧的" ✗。
|
|
91
|
+
- **假 DOM 不支持 `#id` 选择器** ✗ → 地址栏带 `data-dsh-part="url-router-address-bar"` ✓,测试用属性选择器 ✓(行锚点当年同理 ✓)。
|
|
92
|
+
- **验收**:布局可在 Web 版上用 `document.documentElement.setAttribute('data-windows-titlebar','')` + 那个 CSS 变量预演 ✓(同一套 CSS ✓),但 `dsh-app://` 下的 `replaceState` / `hashchange` 行为**只能真机确认** ✓。
|
|
93
|
+
|
|
60
94
|
## 跨机开发日志
|
|
61
95
|
|
|
62
96
|
改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-url-router
|
|
2
2
|
|
|
3
|
-
**DSH Web
|
|
3
|
+
**DSH 地址栏路由插件(Web 与 Windows 桌面版)—— 每个界面都有自己的链接:地址栏写着屏幕上的东西,链接点开就是那个界面。** 给 DeepSeek Harness (dsh) 补上"可寻址":点侧栏会话,地址栏变成 `#/session/<id>`;点插件面板,变成 `#/panel/plugins`;点进某个插件、某个组件、某个任务,地址栏跟着一起走。把这条链接贴到新标签页、或者对着侧栏任意一行右键,就是浏览器自己的行为——无需复制按钮、无需自定义菜单。桌面版(Windows)自己没有地址栏,本插件就把它画在标题栏那条空白里。
|
|
4
4
|
|
|
5
5
|
它是**地址栏的唯一写者**:只改 URL 的 fragment,路径与查询串(例如启动时的 `?token=…`)原样保留;只用 `replaceState`,浏览面板不会往后退栈里塞历史。**只有浏览器半区**——零依赖、无宿主路由、无遥测。
|
|
6
6
|
|
|
@@ -25,6 +25,8 @@ dsh plugin --profile web add dsh-url-router
|
|
|
25
25
|
dsh plugin --profile web add "link:/path/to/dsh-url-router"
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
**已登记进社区插件索引** ✓ —— [zhu1090093659/dsh-community-plugins](https://github.com/zhu1090093659/dsh-community-plugins) 的 `community.json` 是本插件在 DSH Workshop 商店与 dsh-market.com 清单里的唯一数据源 ✓(登记条目 `dsh-url-router`,`ui/panel` ✓)。
|
|
29
|
+
|
|
28
30
|
### 路由表
|
|
29
31
|
|
|
30
32
|
| 地址 | 界面 | 由谁打开 |
|
|
@@ -49,11 +51,21 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
|
|
|
49
51
|
- **中键、Cmd/Ctrl + 单击** → 原生新标签页
|
|
50
52
|
- **左键单击** → 在本页打开(`preventDefault` 后调官方导航,不新增历史记录)
|
|
51
53
|
|
|
54
|
+
### 桌面版的地址栏
|
|
55
|
+
|
|
56
|
+
DSH 桌面版(Windows)的标题栏是**画在网页里的**:preload 给 `<html>` 打上 `data-windows-titlebar`、给出 `--dsh-windows-titlebar-height`,菜单条本身是页面里的一个 shadow DOM 固定元素(`[data-windows-menu]`)。那条 strip 里的空白是**页面空间**,而桌面版**自己没有地址栏** —— 于是本插件把地址栏放在那儿:
|
|
57
|
+
|
|
58
|
+
- 只在 `html[data-windows-titlebar]` 存在时出现 ✓(Web 版与 macOS 上什么都不画 ✓)。
|
|
59
|
+
- 位置:紧跟 shell 自己的菜单条右侧 ✓、右端避开窗口按钮 ✓;高度取 `--dsh-windows-titlebar-height` ✓;`-webkit-app-region: no-drag` 保证能点 ✓。
|
|
60
|
+
- 输入什么 ✓:任何 `#/…` 路由(会话 / 工作区 / 视图 / 轮次 / 行 / 面板 / 设置三层 / 右栏 pane)、**裸会话 id**、或**整条链接**(只取它的 fragment)✓;回车写 hash → 浏览器触发 `hashchange` → 落地走的就是"粘贴链接"那条已验证的路 ✓。
|
|
61
|
+
- 它同时是**反映** ✓:本插件每次写地址栏都会同步进这个框 ✓,所以它显示的就是"现在在哪" ✓。Escape 还原 ✓;认不出的文本不动地址栏 ✓。
|
|
62
|
+
|
|
52
63
|
### 它守的规矩
|
|
53
64
|
|
|
54
65
|
- **只动 fragment**:路径与查询串从不改写(启动 `?token=…` 能活下来)。
|
|
55
66
|
- **唯一写者**:本插件拥有 fragment,请**不要**和别的写 fragment 的插件同时启用(例如 `dsh-session-url`),两者会互相覆盖。
|
|
56
67
|
- **只用 `replaceState`**:浏览面板不污染后退栈。
|
|
68
|
+
- **链接落地前,挡在前面的表面先让开** ✓:主面板选中时地址栏读到的就是面板 ✓,设置浮层在屏时读到的就是 `#/settings/<分区>` ✓ —— 这两种情况下会话**其实已经在背后打开了** ✗,屏幕上却像"没反应" ✓。所以驱动任何非设置链接之前 ✓,先 `beginNavigation()` + `selectPanel(null)` 让面板退场 ✓,再用**官方自己那条 document 级 Escape** 把设置浮层请走 ✓(模态栈是官方内部的私有注册表 ✗ 外部拿不到 ⇒ Escape 是浏览器侧唯一可用的官方关闭路径 ✓,也就是你按 Esc 走的那条 ✓)。**一条链接只按一次** ✓:不会在你正在操作页面时乱按键 ✓,代价只是万一那次没关掉、你再粘一次 ✓。设置自己的链接**不动**浮层 ✓。
|
|
57
69
|
- **轮次信息:能证明才写** ✓。助手行自己的身份里就带轮次(`row/assistant/80/32` = 第 80 轮第 32 项 ✓);**工具行不带** ✗(只有调用 id ✓)→ 于是我从视图的**轮次边界** `[data-turn-start]` 反推 ✓,**但只在行夹在两个已渲染边界之间时才写** ✓(`turn/82/row/tool/call/…` ✓)。视图是虚拟化的 ✓,边界可能只渲染了一侧 ✗ → 那时**宁可不写轮次** ✓,也绝不猜一个错的轮次 ✗。
|
|
58
70
|
- **轨迹里的每一行都有自己的链接** ✓:`#/session/<id>/row/<类>/<作用域>/<id>`(例:`row/tool/call/call_ce3a…`、`row/assistant/80/32` = 第 80 轮第 32 项 ✓;有工作区时跟在会话 id 之后 ✓)。身份来自视图自己发的 **`data-trajectory-row-key`** ✓(值是 `<类>%00<作用域>%00<id>` ✓,我把它拆成可读路径 ✓);你家 Trajectory Plus 发的是 **`data-dshts-turn`**(轮号 ✓)→ 那种行按轮次写(`turn/<n>` ✓)。点一行就反映进地址栏 ✓(**纯被动**:不 preventDefault ✓)。粘贴行链接会打开那个对话并**尽力滚到那一行** ✓ —— 视图是虚拟化的 ✓,**没渲染出来的行不会被假装滚到** ✗(不会骗你 ✓)。
|
|
59
71
|
- **会话视图(对话 / 轨迹 / 轨迹plus …)也进地址栏** ✓:`#/session/<id>/view/<viewId>`(有工作区时是 `#/workspace/<名>/session/<id>/view/<viewId>` ✓)。**视图 id 是真的 id** ✓(来自槽位 `conversation.view` 的 `id` 字段 ✓,按 `order` 排序后与头部页签一一对应 ✓):实测本机是 `chat` / `trajectory` / **`surface`**(Trajectory Plus ✓)/ **`plus2`**(Trajectory Plus 2 ✓)。默认视图(第一个 ✓)**不写进链接** ✓,所以普通对话链接保持短 ✓。**粘贴视图链接会真的切过去** ✓(与其它覆盖层不同:这是链接存在的意义 ✓)。
|
|
@@ -61,13 +73,14 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
|
|
|
61
73
|
- **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接不带会话 id。
|
|
62
74
|
- **链接落地即交权**:视图一旦变成链接描述的那个界面,链接立刻失去权威——这样你在面板里继续点进去时,地址栏不会被拽回上一层。
|
|
63
75
|
- **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是抛异常的;所有宿主面调用都被容纳,失败只留一条诊断并回落到屏幕上真实的视图。
|
|
76
|
+
- **侧栏扫描有预算** ✓:一帧内的多次 DOM 变更合并成**一次**扫描 ✓;只带文本/普通元素的批次**直接跳过** ✓(流式输出不会一帧扫一次 ✓);工作区归属**一趟遍历**解决 ✓(不是每行走一遍树 ✗);已挂锚的行不再重挂 ✓,锚点被视图丢掉时下一趟自动补回 ✓。
|
|
64
77
|
|
|
65
78
|
### 已知限制
|
|
66
79
|
|
|
67
80
|
- **轮次链接是"位置",而且现在真能跳** ✓:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;**粘贴它会打开那个对话、把历史翻页回那一轮、再滚到它** ✓(真机实测:贴 `/turn/118` → 窗口变成 118→121、目标在视口 true、最顶可见正是 118 ✓)。用的是**官方已有公开契约** ✓:`sessions.retainScope(id, source)` 拿到会话控制器 ✓ → `session.projections.faceOf('turnOutline')` 读到那一轮的 `seq` ✓ → `session.loadThrough(seq)` 翻页回去 ✓(完事 `reference.release()` ✓)→ 再按官方视口同款选择器 `[data-chat-turn]:not([hidden])` 滚到它 ✓。座位高度/相交都读不到时**不算到位** ✓,宁可继续重试也不假报 ✗。
|
|
68
81
|
- **右栏 pane 已闭环** ✓:`#/session/<id>/pane/<kind>` 与 `#/panel/<面板>/pane/<kind>` ✓(真机验证:贴链接开出对应页签 ✓、地址栏跟着切换 ✓)。页签种类由右栏自己注册 ✓(实测 7 种:`guide` / `subagentchat` / `changes-review` / `plan` / `files` / `text` / `terminal` ✓)。
|
|
69
82
|
- **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
|
|
70
|
-
-
|
|
83
|
+
- **设置页分区的身份只有显示名**(上一条的含义,别当成 bug 去找官方要 id ✗):官方那个 `openSection(id)` 里的 id **不对外** ✗,DOM 上也没有任何 `data-*` ✓ —— 所以链接里写的就是用户读到的那行字 ✓,官方改文案或换语言后旧链接失效是**设计如此** ✓。
|
|
71
84
|
- **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(**反映 + 翻页 + 滚动 ✓ 全链路已闭环**)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。轮次跳转用的是官方公开 API ✓,**没有等待任何新接缝** ✓(`uiChat.scrollToTurn` 那条请求已降级为锦上添花 ✓,讨论见 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
|
|
72
85
|
- **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
|
|
73
86
|
- **和别的 fragment 写者互斥**(例如写 `#msg=` 的"跳到消息"插件):一个 URL 只有一个 fragment ✓,两个写者只能轮流拥有 ✗。本插件**不假装合并** ✓ —— 它认出外来 fragment ✓、**绝不覆盖** ✓,并明确说"只能启用一个写者" ✓(而不是互相静默擦掉对方的链接 ✗)。
|
|
@@ -94,6 +107,7 @@ dsh plugin --profile web add dsh-url-router
|
|
|
94
107
|
# restart `dsh web`, then hard-refresh the browser
|
|
95
108
|
```
|
|
96
109
|
|
|
110
|
+
Listed in the community plugin index: [zhu1090093659/dsh-community-plugins](https://github.com/zhu1090093659/dsh-community-plugins) keeps `community.json`, the single source for the DSH Workshop store catalog and the dsh-market.com manifest (entry `dsh-url-router`, `ui/panel`).
|
|
97
111
|
### Routes
|
|
98
112
|
|
|
99
113
|
| Route | Surface |
|
|
@@ -112,6 +126,15 @@ A kind it does not know is left alone with a single diagnostic (a fragment has e
|
|
|
112
126
|
|
|
113
127
|
Conversation rows and both plugin-list groups carry a transparent full-row anchor: right-click gives the native menu, middle/Cmd-click opens a native new tab, and a plain left click navigates in place without adding a history entry.
|
|
114
128
|
|
|
129
|
+
### The desktop address bar
|
|
130
|
+
|
|
131
|
+
The Windows desktop window draws its own title bar **inside the page**: the preload marks `<html data-windows-titlebar>` and publishes `--dsh-windows-titlebar-height`, and its menu is a fixed shadow-DOM element in the page (`[data-windows-menu]`). That strip is page space, and the desktop window has **no address bar of its own** — so this plugin puts one there:
|
|
132
|
+
|
|
133
|
+
- Drawn only when `html[data-windows-titlebar]` is present (nothing at all on the web app or macOS).
|
|
134
|
+
- Placed right after the shell's own menu and before the window buttons, at `--dsh-windows-titlebar-height`, with `-webkit-app-region: no-drag` so it stays clickable.
|
|
135
|
+
- Accepts any `#/…` route, a bare conversation id, or a whole link (only its fragment is used); Enter writes the hash, the browser fires `hashchange`, and the link lands through the very same path a pasted link takes.
|
|
136
|
+
- It also **reflects**: every write this plugin makes to the address bar is mirrored into the box, so it always names what is on screen. Escape restores it; text that names no route is refused.
|
|
137
|
+
|
|
115
138
|
### Rules it keeps
|
|
116
139
|
|
|
117
140
|
- **Fragment only.** Path and query are never rewritten.
|
package/lib/client.js
CHANGED
|
@@ -66,6 +66,14 @@ window.__ModuleLoader__.load({
|
|
|
66
66
|
const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
|
|
67
67
|
/** Attribute the settings overlay puts on its root element. */
|
|
68
68
|
const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
|
|
69
|
+
/**
|
|
70
|
+
* The marker the official build puts on the settings overlay. `data-dsh-surface` was never
|
|
71
|
+
* official: the third-party skin centre stamps it through its own compat adapter, and that
|
|
72
|
+
* contract says out loud it is not public and may be removed. Any profile without that
|
|
73
|
+
* plugin — the desktop window is one — has only this marker, so a router that knows only
|
|
74
|
+
* the other name silently stops naming the overlay.
|
|
75
|
+
*/
|
|
76
|
+
const SETTINGS_MODAL_ATTRIBUTE = 'data-shortcut-modal'
|
|
69
77
|
/** Value of that attribute for the settings overlay. */
|
|
70
78
|
const SETTINGS_SURFACE_VALUE = 'settings'
|
|
71
79
|
/** Slot the settings entry is rendered into (its button opens the overlay). */
|
|
@@ -123,10 +131,75 @@ window.__ModuleLoader__.load({
|
|
|
123
131
|
* The router's own version, for the report a human files. A test keeps it equal to the
|
|
124
132
|
* package version, so a release cannot forget to bump it.
|
|
125
133
|
*/
|
|
126
|
-
const ROUTER_VERSION = '0.
|
|
134
|
+
const ROUTER_VERSION = '0.4.0'
|
|
135
|
+
/**
|
|
136
|
+
* Hand-maintained build marker, reported alongside the version. A working copy that is
|
|
137
|
+
* linked into a profile serves this file straight from disk, so the package version
|
|
138
|
+
* cannot say *which* copy a live window loaded — this can, and `report().build` is how a
|
|
139
|
+
* real-machine check answers that. Bump it whenever a live window must be told apart.
|
|
140
|
+
*/
|
|
141
|
+
const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss'
|
|
127
142
|
|
|
128
143
|
const TAG = '[url-router]'
|
|
129
144
|
|
|
145
|
+
/** Marks the Windows desktop title bar the shell draws inside the page. */
|
|
146
|
+
const WINDOWS_TITLEBAR_ATTRIBUTE = 'data-windows-titlebar'
|
|
147
|
+
/** The shell's own menu host inside that strip; the address bar starts after it. */
|
|
148
|
+
const WINDOWS_MENU_ATTRIBUTE = 'data-windows-menu'
|
|
149
|
+
/** The shell's own left inset for that menu, matching its fallback. */
|
|
150
|
+
const WINDOWS_MENU_START = 48
|
|
151
|
+
/** Id of the desktop address bar and of its stylesheet. */
|
|
152
|
+
const TITLEBAR_BAR_ID = 'dsh-url-router-titlebar'
|
|
153
|
+
/** The bar's structural hook, the way the row anchors carry theirs. */
|
|
154
|
+
const TITLEBAR_PART = 'url-router-address-bar'
|
|
155
|
+
const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
|
|
156
|
+
/** Width the window's caption buttons take at the right end of the strip. */
|
|
157
|
+
const TITLEBAR_CAPTION_WIDTH = 138
|
|
158
|
+
/** Gap between the shell's menu and the bar. */
|
|
159
|
+
const TITLEBAR_GAP = 8
|
|
160
|
+
/** How long the bar waits for a handed-over link to land before it says it did not. */
|
|
161
|
+
const TITLEBAR_SETTLE_MS = 900
|
|
162
|
+
/** What the address bar accepts, said in its placeholder. */
|
|
163
|
+
const TITLEBAR_PLACEHOLDER = '#/session/… · #/panel/… · #/settings · 会话 id · 链接'
|
|
164
|
+
/** The strip belongs to the shell, so only the bar itself is styled. */
|
|
165
|
+
const TITLEBAR_STYLE_TEXT = [
|
|
166
|
+
`#${TITLEBAR_BAR_ID}{position:fixed;top:0;height:var(--dsh-windows-titlebar-height,40px);display:flex;align-items:center;z-index:1200;-webkit-app-region:no-drag}`,
|
|
167
|
+
`#${TITLEBAR_BAR_ID} input{height:26px;width:100%;box-sizing:border-box;border:1px solid transparent;border-radius:6px;padding:0 8px;font:inherit;font-size:12px;color:inherit;background:var(--dsw-specific-sidebar-fill,rgba(127,127,127,.14));outline:none;-webkit-app-region:no-drag}`,
|
|
168
|
+
`#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
169
|
+
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
170
|
+
].join('')
|
|
171
|
+
/** Listeners told the fragment this router just wrote; the desktop bar reads it. */
|
|
172
|
+
const routeFeed = new Set()
|
|
173
|
+
/**
|
|
174
|
+
* Tell the feed what the address bar now says.
|
|
175
|
+
* @param {string} hash - the fragment just written.
|
|
176
|
+
*/
|
|
177
|
+
function publishRoute(hash) {
|
|
178
|
+
for (const listener of [...routeFeed]) {
|
|
179
|
+
try {
|
|
180
|
+
listener(hash)
|
|
181
|
+
} catch {
|
|
182
|
+
// A reader must never break a write.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Read every fragment this router writes.
|
|
188
|
+
* @param {Function} listener - called with the fragment.
|
|
189
|
+
* @returns {() => void} the unsubscribe.
|
|
190
|
+
*/
|
|
191
|
+
function subscribeRoute(listener) {
|
|
192
|
+
routeFeed.add(listener)
|
|
193
|
+
return () => routeFeed.delete(listener)
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* How the desktop address bar hands a typed fragment over. The routing state machine
|
|
197
|
+
* installs it (it owns the adoption path) and leaves it a no-op until then, so a bar
|
|
198
|
+
* drawn before the machine is live cannot navigate anything.
|
|
199
|
+
* @param {string} fragment - the fragment to adopt.
|
|
200
|
+
*/
|
|
201
|
+
let driveTypedFragment = () => {}
|
|
202
|
+
|
|
130
203
|
/**
|
|
131
204
|
* The conversation view ids the registry publishes, in registration order — the same order
|
|
132
205
|
* the header renders its tabs in, which is what lets a tab position map back to an id.
|
|
@@ -164,6 +237,29 @@ window.__ModuleLoader__.load({
|
|
|
164
237
|
}
|
|
165
238
|
}
|
|
166
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the workspace name for every row in one pass over the tree, instead of
|
|
242
|
+
* walking the tree again for every row that needs a name.
|
|
243
|
+
* @param {object} document - the document the tree lives in.
|
|
244
|
+
* @returns {Map<object, string|undefined>} row -> its workspace display name.
|
|
245
|
+
*/
|
|
246
|
+
function workspaceNamesOfRows(document) {
|
|
247
|
+
const names = new Map()
|
|
248
|
+
const rows = document?.querySelectorAll?.('[data-row-key]')
|
|
249
|
+
if (rows === undefined || rows === null) return names
|
|
250
|
+
let name
|
|
251
|
+
for (const candidate of rows) {
|
|
252
|
+
const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
|
|
253
|
+
if (typeof key !== 'string') continue
|
|
254
|
+
if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
|
|
255
|
+
name = (candidate.textContent ?? '').trim()
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
names.set(candidate, name === undefined || name === '' ? undefined : name)
|
|
259
|
+
}
|
|
260
|
+
return names
|
|
261
|
+
}
|
|
262
|
+
|
|
167
263
|
/**
|
|
168
264
|
* The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
|
|
169
265
|
* row text. Returns undefined when the tree says nothing, so no caller has to guess.
|
|
@@ -567,6 +663,8 @@ window.__ModuleLoader__.load({
|
|
|
567
663
|
let panelAttempts = 0
|
|
568
664
|
/** How many times a panel link's inner page was requested (bounded, see below). */
|
|
569
665
|
let innerAttempts = 0
|
|
666
|
+
/** Whether the current link already asked for a modal standing in front of it to go away. */
|
|
667
|
+
let overlayDismissed = false
|
|
570
668
|
/** The view state observed on the previous notification. */
|
|
571
669
|
let seen = 'none'
|
|
572
670
|
let cancelTimer
|
|
@@ -593,7 +691,7 @@ window.__ModuleLoader__.load({
|
|
|
593
691
|
const canonical = () => {
|
|
594
692
|
// The settings overlay covers the main column, so while it is up it is what is on
|
|
595
693
|
// screen; when it closes the ordinary surface takes the address bar back.
|
|
596
|
-
if (window.document
|
|
694
|
+
if (settingsOverlayOf(window.document) !== undefined) {
|
|
597
695
|
const section = host.activeSettingsSection() ?? ''
|
|
598
696
|
const tab = section === '' ? undefined : host.activeSettingsTab()
|
|
599
697
|
return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
|
|
@@ -755,6 +853,7 @@ window.__ModuleLoader__.load({
|
|
|
755
853
|
// (this also leaves a foreign fragment alone, which the caller never reaches).
|
|
756
854
|
if (route === undefined && hash === '') return
|
|
757
855
|
host.replaceUrl(routeUrl(pathname, search, route))
|
|
856
|
+
publishRoute(wantedHash)
|
|
758
857
|
}
|
|
759
858
|
/**
|
|
760
859
|
* Re-read the view state and move towards the state the address bar describes.
|
|
@@ -801,6 +900,29 @@ window.__ModuleLoader__.load({
|
|
|
801
900
|
reflect(canonical())
|
|
802
901
|
return
|
|
803
902
|
}
|
|
903
|
+
// Anything that names a conversation — session, workspace, turn, row, view — has to
|
|
904
|
+
// take the main column back from a panel standing in front of it. Without this the
|
|
905
|
+
// panel stays selected, the canonical form stays the panel's, and the link reads as
|
|
906
|
+
// "nothing happened" (measured on the desktop address bar: the workspace-shaped link
|
|
907
|
+
// returns through its own branch below, so closing the panel in one branch only was
|
|
908
|
+
// not enough). A settings overlay standing in front is dealt with right below.
|
|
909
|
+
if (link.kind !== 'panel' && link.kind !== 'settings') {
|
|
910
|
+
const panelInFront = host.activePanelId()
|
|
911
|
+
if (panelInFront !== undefined && panelInFront !== null) {
|
|
912
|
+
host.beginNavigation()
|
|
913
|
+
host.selectPanel(null)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// The settings overlay covers the main column too, and while it is up it *is* what the
|
|
917
|
+
// address bar names — so a link behind it can never land and expires as "the view did
|
|
918
|
+
// not move" (measured on the desktop: overlay on 通用设置, a pasted workspace link
|
|
919
|
+
// opened its conversation out of sight and the bar stayed on `#/settings/通用设置`).
|
|
920
|
+
// Dismiss it once per link: Escape on every notification would fire on a page the user
|
|
921
|
+
// is working in, and a link that misses simply has to be pasted again.
|
|
922
|
+
if (link.kind !== 'settings' && !overlayDismissed) {
|
|
923
|
+
overlayDismissed = true
|
|
924
|
+
host.closeSettings()
|
|
925
|
+
}
|
|
804
926
|
// A conversation link may name which view it wants, on either route shape; the view is
|
|
805
927
|
// driven before the plain conversation/workspace branches, or they would swallow it.
|
|
806
928
|
const rowParts = innerPath(link.inner)
|
|
@@ -995,6 +1117,7 @@ window.__ModuleLoader__.load({
|
|
|
995
1117
|
claimed = false
|
|
996
1118
|
panelAttempts = 0
|
|
997
1119
|
innerAttempts = 0
|
|
1120
|
+
overlayDismissed = false
|
|
998
1121
|
seen = 'none'
|
|
999
1122
|
}
|
|
1000
1123
|
dropTimer()
|
|
@@ -1042,6 +1165,18 @@ window.__ModuleLoader__.load({
|
|
|
1042
1165
|
retryTimer = undefined
|
|
1043
1166
|
}
|
|
1044
1167
|
const safeReadLink = () => guard(readLink)
|
|
1168
|
+
// The desktop address bar hands its fragment over here instead of relying on the
|
|
1169
|
+
// browser: a programmatic hash write fires `hashchange` in a browser, but a hash
|
|
1170
|
+
// equal to the current one fires nothing at all, and the desktop shell serves the
|
|
1171
|
+
// page over its own scheme. Adopting through the same guarded entry the hashchange
|
|
1172
|
+
// listener uses makes the bar work in both cases. The hash is still written, so the
|
|
1173
|
+
// address bar, Back/Forward and copied links all agree with what is on screen.
|
|
1174
|
+
driveTypedFragment = fragment => {
|
|
1175
|
+
attemptValue(() => {
|
|
1176
|
+
window.location.hash = fragment
|
|
1177
|
+
})
|
|
1178
|
+
safeReadLink()
|
|
1179
|
+
}
|
|
1045
1180
|
const offPanel = host.onPanelChange(() => safeReconcile('panel'))
|
|
1046
1181
|
const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
|
|
1047
1182
|
const offDom = host.onDomChange(() => safeReconcile('dom'))
|
|
@@ -1110,6 +1245,7 @@ window.__ModuleLoader__.load({
|
|
|
1110
1245
|
const count = selector => attemptValue(() => document?.querySelectorAll?.(selector)?.length) ?? '\u2014'
|
|
1111
1246
|
return {
|
|
1112
1247
|
version: ROUTER_VERSION,
|
|
1248
|
+
build: ROUTER_BUILD,
|
|
1113
1249
|
hash: attemptValue(() => host.readLocation().hash) ?? '',
|
|
1114
1250
|
canonical: current === undefined ? null : { kind: current.kind, id: current.id, inner: innerPath(current.inner) },
|
|
1115
1251
|
link: linked === undefined ? null : { hash: routeHash(linked.kind, linked.id, linked.inner), attempts: { panel: panelAttempts, inner: innerAttempts } },
|
|
@@ -1123,7 +1259,7 @@ window.__ModuleLoader__.load({
|
|
|
1123
1259
|
turnBoundaries: count(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`),
|
|
1124
1260
|
pluginPackages: count('[data-plugin-package]'),
|
|
1125
1261
|
officialItems: count('[data-plugin-item]'),
|
|
1126
|
-
settingsOverlay:
|
|
1262
|
+
settingsOverlay: settingsOverlayOf(document) === undefined ? 0 : 1,
|
|
1127
1263
|
settingsSection: attemptValue(() => host.activeSettingsSection()) ?? null,
|
|
1128
1264
|
taskBoardOpen: count(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`),
|
|
1129
1265
|
taskBoardNew: count(`[${TASK_BOARD_NEW_ATTRIBUTE}]`),
|
|
@@ -1137,7 +1273,7 @@ window.__ModuleLoader__.load({
|
|
|
1137
1273
|
}
|
|
1138
1274
|
}
|
|
1139
1275
|
guard(() => {
|
|
1140
|
-
window.__dshUrlRouter = { version: ROUTER_VERSION, report, when: host.now() }
|
|
1276
|
+
window.__dshUrlRouter = { version: ROUTER_VERSION, build: ROUTER_BUILD, report, when: host.now() }
|
|
1141
1277
|
})
|
|
1142
1278
|
|
|
1143
1279
|
return () => {
|
|
@@ -1322,8 +1458,12 @@ window.__ModuleLoader__.load({
|
|
|
1322
1458
|
function panelRows(document) {
|
|
1323
1459
|
const rows = []
|
|
1324
1460
|
try {
|
|
1325
|
-
|
|
1326
|
-
|
|
1461
|
+
// One attribute query beats walking every button in the document and
|
|
1462
|
+
// querying each button's subtree: the panel list publishes its slot on
|
|
1463
|
+
// exactly one element per row, so the slot itself is the index.
|
|
1464
|
+
for (const slot of document.querySelectorAll(`[data-slot="${PANEL_LIST_SLOT}"]`)) {
|
|
1465
|
+
const host = typeof slot.closest === 'function' ? slot.closest('button') : null
|
|
1466
|
+
if (host !== null && !rows.includes(host)) rows.push(host)
|
|
1327
1467
|
}
|
|
1328
1468
|
} catch {
|
|
1329
1469
|
return []
|
|
@@ -1338,12 +1478,14 @@ window.__ModuleLoader__.load({
|
|
|
1338
1478
|
* @param {Function} openSession - navigation for a plain left click.
|
|
1339
1479
|
* @returns {boolean} whether the row now carries an anchor.
|
|
1340
1480
|
*/
|
|
1341
|
-
function enhanceRow(document, row, openSession) {
|
|
1481
|
+
function enhanceRow(document, row, openSession, knownWorkspaceName) {
|
|
1342
1482
|
const id = sessionIdOfRow(row)
|
|
1343
1483
|
if (id === undefined) return false
|
|
1344
1484
|
// The workspace the row sits under leads the link, so a copied link says where the
|
|
1345
1485
|
// conversation lives. No workspace row in the tree means no guess: the plain route.
|
|
1346
|
-
|
|
1486
|
+
// A caller that already resolved the tree hands the name in, so N rows cost one
|
|
1487
|
+
// pass over the tree instead of N.
|
|
1488
|
+
const workspaceName = arguments.length > 3 ? knownWorkspaceName : workspaceNameOf(row, document)
|
|
1347
1489
|
const href = workspaceName === undefined
|
|
1348
1490
|
? routeHash('session', id)
|
|
1349
1491
|
: routeHash('workspace', workspaceName, innerPath([WORKSPACE_SESSION_SEGMENT, id]))
|
|
@@ -1384,7 +1526,18 @@ window.__ModuleLoader__.load({
|
|
|
1384
1526
|
const scan = () => {
|
|
1385
1527
|
let linked = 0
|
|
1386
1528
|
try {
|
|
1387
|
-
|
|
1529
|
+
const rows = document.querySelectorAll(ROW_SELECTOR)
|
|
1530
|
+
let names
|
|
1531
|
+
for (const row of rows) {
|
|
1532
|
+
// Already a link, and the anchor is still in the row: remounting it
|
|
1533
|
+
// costs a computed style and a tree walk and buys nothing. The check is a
|
|
1534
|
+
// query inside one row (a handful of nodes), never a sweep of the document,
|
|
1535
|
+
// so a rescan stays proportional to the rows that need one — and a row whose
|
|
1536
|
+
// anchor a re-render dropped is healed on the next pass.
|
|
1537
|
+
if (typeof row.querySelector === 'function' && row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) continue
|
|
1538
|
+
names ??= workspaceNamesOfRows(document)
|
|
1539
|
+
if (enhanceRow(document, row, openSession, names.get(row))) linked += 1
|
|
1540
|
+
}
|
|
1388
1541
|
} catch {
|
|
1389
1542
|
// A DOM that cannot be scanned is left untouched: the address-bar sync
|
|
1390
1543
|
// is the plugin's contract, the row links are an addition to it.
|
|
@@ -1444,10 +1597,64 @@ window.__ModuleLoader__.load({
|
|
|
1444
1597
|
style = undefined
|
|
1445
1598
|
}
|
|
1446
1599
|
let observer
|
|
1600
|
+
let frame
|
|
1601
|
+
let queued = false
|
|
1602
|
+
const runScan = () => {
|
|
1603
|
+
queued = false
|
|
1604
|
+
frame = undefined
|
|
1605
|
+
scan()
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Coalesce mutation batches into one scan per frame: a streaming answer mutates
|
|
1609
|
+
* the transcript many times per frame, and every batch used to buy a whole-document
|
|
1610
|
+
* scan. Without animation frames the scan runs straight away, so a minimal DOM
|
|
1611
|
+
* never waits on a frame that will not come.
|
|
1612
|
+
*/
|
|
1613
|
+
const scheduleScan = () => {
|
|
1614
|
+
if (queued) return
|
|
1615
|
+
if (typeof window.requestAnimationFrame !== 'function') {
|
|
1616
|
+
scan()
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1619
|
+
queued = true
|
|
1620
|
+
frame = window.requestAnimationFrame(runScan)
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Only a mutation batch that can introduce a row is worth a scan. Text nodes and
|
|
1624
|
+
* ordinary elements arrive constantly (streaming, hover, tool output) and never
|
|
1625
|
+
* carry a row.
|
|
1626
|
+
* @param {object[]} mutations - the batch the observer delivered.
|
|
1627
|
+
* @returns {boolean} whether a scan is warranted.
|
|
1628
|
+
*/
|
|
1629
|
+
const rowMayHaveArrived = mutations => {
|
|
1630
|
+
// A batch delivered empty says nothing about what changed: scan rather than guess.
|
|
1631
|
+
if (!Array.isArray(mutations) || mutations.length === 0) return true
|
|
1632
|
+
for (const mutation of mutations) {
|
|
1633
|
+
if (mutation === null || mutation === undefined) continue
|
|
1634
|
+
if (mutation.type !== 'childList') continue
|
|
1635
|
+
const added = mutation.addedNodes ?? []
|
|
1636
|
+
for (const node of added) {
|
|
1637
|
+
// Skip only what is provably not an element: a text or comment node can never
|
|
1638
|
+
// carry a row, while a DOM without nodeType still gets looked at.
|
|
1639
|
+
if (node === null || node === undefined) continue
|
|
1640
|
+
if (node.nodeType !== undefined && node.nodeType !== 1) continue
|
|
1641
|
+
if (typeof node.matches !== 'function') return true
|
|
1642
|
+
if (node.matches(ROW_SELECTOR) || node.matches('[data-plugin-item],[data-plugin-package]')) return true
|
|
1643
|
+
if (node.matches(`[data-slot="${PANEL_LIST_SLOT}"]`)) return true
|
|
1644
|
+
if (typeof node.querySelector === 'function') {
|
|
1645
|
+
if (node.querySelector(ROW_SELECTOR) !== null) return true
|
|
1646
|
+
if (node.querySelector('[data-plugin-item],[data-plugin-package]') !== null) return true
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return false
|
|
1651
|
+
}
|
|
1447
1652
|
try {
|
|
1448
1653
|
const root = document.body ?? document.documentElement
|
|
1449
1654
|
if (root !== undefined && root !== null && typeof window.MutationObserver === 'function') {
|
|
1450
|
-
observer = new window.MutationObserver(
|
|
1655
|
+
observer = new window.MutationObserver(mutations => {
|
|
1656
|
+
if (rowMayHaveArrived(mutations)) scheduleScan()
|
|
1657
|
+
})
|
|
1451
1658
|
observer.observe(root, { childList: true, subtree: true })
|
|
1452
1659
|
}
|
|
1453
1660
|
} catch {
|
|
@@ -1459,6 +1666,9 @@ window.__ModuleLoader__.load({
|
|
|
1459
1666
|
return () => {
|
|
1460
1667
|
try {
|
|
1461
1668
|
observer?.disconnect()
|
|
1669
|
+
if (queued && typeof window.cancelAnimationFrame === 'function' && frame !== undefined) window.cancelAnimationFrame(frame)
|
|
1670
|
+
queued = false
|
|
1671
|
+
frame = undefined
|
|
1462
1672
|
style?.remove()
|
|
1463
1673
|
for (const row of [...document.querySelectorAll(ROW_SELECTOR), ...panelRows(document)]) {
|
|
1464
1674
|
row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`)?.remove()
|
|
@@ -1841,7 +2051,7 @@ window.__ModuleLoader__.load({
|
|
|
1841
2051
|
// One selector at a time (a comma list is beyond a small DOM stub) and the `nav`
|
|
1842
2052
|
// ancestor check keeps unrelated aria-current controls out of it.
|
|
1843
2053
|
activeSettingsSection: () => {
|
|
1844
|
-
const overlay = window.document
|
|
2054
|
+
const overlay = settingsOverlayOf(window.document)
|
|
1845
2055
|
if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
|
|
1846
2056
|
for (const candidate of overlay.querySelectorAll('button')) {
|
|
1847
2057
|
if (typeof candidate.getAttribute !== 'function') continue
|
|
@@ -1854,7 +2064,7 @@ window.__ModuleLoader__.load({
|
|
|
1854
2064
|
},
|
|
1855
2065
|
// The tab a settings section is showing: the same ARIA contract one level deeper.
|
|
1856
2066
|
activeSettingsTab: () => {
|
|
1857
|
-
const overlay = window.document
|
|
2067
|
+
const overlay = settingsOverlayOf(window.document)
|
|
1858
2068
|
if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
|
|
1859
2069
|
for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
|
|
1860
2070
|
if (typeof candidate.getAttribute !== 'function') continue
|
|
@@ -1864,10 +2074,23 @@ window.__ModuleLoader__.load({
|
|
|
1864
2074
|
}
|
|
1865
2075
|
return undefined
|
|
1866
2076
|
},
|
|
2077
|
+
// The overlay is a modal, and the platform's own dismiss path is a document-level
|
|
2078
|
+
// Escape: the shipped modal layer registers `document.addEventListener('keydown',
|
|
2079
|
+
// handler, true)` gated on `key === 'Escape'` and closes the topmost modal there.
|
|
2080
|
+
// The registry that official `closeTopModal` walks is module-private to the bundle,
|
|
2081
|
+
// so this key event is the only dismiss route a browser-half plugin has — and it is
|
|
2082
|
+
// the same path the user's Esc key takes.
|
|
2083
|
+
closeSettings: () => {
|
|
2084
|
+
if (settingsOverlayOf(window.document) === undefined) return
|
|
2085
|
+
attempt(() => {
|
|
2086
|
+
const event = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
2087
|
+
window.document?.dispatchEvent?.(event)
|
|
2088
|
+
})
|
|
2089
|
+
},
|
|
1867
2090
|
openSettings: (section, tab) => {
|
|
1868
2091
|
if (typeof section === 'string' && section !== '') {
|
|
1869
2092
|
// A section is named by the label the user reads, so the request is a label match.
|
|
1870
|
-
const overlay = window.document
|
|
2093
|
+
const overlay = settingsOverlayOf(window.document)
|
|
1871
2094
|
const scope = overlay ?? window.document
|
|
1872
2095
|
if (typeof scope?.querySelectorAll === 'function') {
|
|
1873
2096
|
const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
|
|
@@ -2053,6 +2276,259 @@ window.__ModuleLoader__.load({
|
|
|
2053
2276
|
* Register the URL routing on the running GUI.
|
|
2054
2277
|
* @param {object} ctx - client root context.
|
|
2055
2278
|
*/
|
|
2279
|
+
// ---------------------------------------------------------------------
|
|
2280
|
+
// desktop address bar: the DSH desktop window has no address bar of its own.
|
|
2281
|
+
//
|
|
2282
|
+
// The Windows shell draws its own 40px title bar inside the page — the preload
|
|
2283
|
+
// marks `<html data-windows-titlebar>` and publishes
|
|
2284
|
+
// `--dsh-windows-titlebar-height` — and leaves the strip between its menu and
|
|
2285
|
+
// the window buttons empty. That strip is page space, so this router can put
|
|
2286
|
+
// its own address bar in it: one input that names a route and opens it, which
|
|
2287
|
+
// is the only way a link reaches the desktop window. Everywhere else (the web
|
|
2288
|
+
// app, macOS) the marker is absent and this draws nothing at all.
|
|
2289
|
+
// ---------------------------------------------------------------------
|
|
2290
|
+
/**
|
|
2291
|
+
* Normalise what was typed into the address bar into a fragment this router owns.
|
|
2292
|
+
* @param {string} raw - the text in the bar.
|
|
2293
|
+
* @returns {string|undefined} the fragment to open, or undefined when nothing can be named.
|
|
2294
|
+
*/
|
|
2295
|
+
function titlebarFragmentOf(raw) {
|
|
2296
|
+
const text = String(raw ?? '').trim()
|
|
2297
|
+
if (text === '') return undefined
|
|
2298
|
+
// A whole link: only its fragment is this router's to open.
|
|
2299
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
|
|
2300
|
+
const at = text.indexOf('#')
|
|
2301
|
+
return at === -1 ? undefined : titlebarFragmentOf(text.slice(at))
|
|
2302
|
+
}
|
|
2303
|
+
// A bare id names that conversation, the way the shell's own menu copies it.
|
|
2304
|
+
if (/^session-[0-9a-z-]{8,}$/i.test(text)) return routeHash('session', text)
|
|
2305
|
+
const fragment = text.startsWith('#') ? text : `#${text.startsWith('/') ? '' : '/'}${text}`
|
|
2306
|
+
return ROUTE_PATTERN.test(fragment) ? fragment : undefined
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* The settings overlay element, when one is on screen. The official marker is tried first;
|
|
2311
|
+
* the other name is only ever present because a third-party theming plugin stamps it, so it
|
|
2312
|
+
* stays a fallback and nothing may depend on it being there.
|
|
2313
|
+
* @param {object} document - the document to look in.
|
|
2314
|
+
* @returns {object|undefined} the overlay element.
|
|
2315
|
+
*/
|
|
2316
|
+
function settingsOverlayOf(document) {
|
|
2317
|
+
for (const attribute of [SETTINGS_MODAL_ATTRIBUTE, SETTINGS_SURFACE_ATTRIBUTE]) {
|
|
2318
|
+
const found = attemptValue(() => document?.querySelector?.(`[${attribute}="${SETTINGS_SURFACE_VALUE}"]`))
|
|
2319
|
+
if (found !== null && found !== undefined) return found
|
|
2320
|
+
}
|
|
2321
|
+
return undefined
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* The fragment a reader can read: percent escapes decoded, or the raw text when the
|
|
2326
|
+
* escapes are malformed. Display only — the router still writes the encoded form.
|
|
2327
|
+
* @param {string} hash - the fragment.
|
|
2328
|
+
* @returns {string} the readable fragment.
|
|
2329
|
+
*/
|
|
2330
|
+
function readableFragment(hash) {
|
|
2331
|
+
const text = String(hash ?? '')
|
|
2332
|
+
try {
|
|
2333
|
+
return decodeURIComponent(text)
|
|
2334
|
+
} catch {
|
|
2335
|
+
return text
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* The conversation a route names, whichever shape it is written in.
|
|
2341
|
+
* @param {string} hash - the fragment.
|
|
2342
|
+
* @returns {string|undefined} the session id, when the route names one.
|
|
2343
|
+
*/
|
|
2344
|
+
function targetSessionOf(hash) {
|
|
2345
|
+
const route = parseRoute(String(hash ?? ''))
|
|
2346
|
+
if (route === undefined || route.unknown !== undefined) return undefined
|
|
2347
|
+
if (route.kind === 'session') return route.id
|
|
2348
|
+
if (route.kind !== 'workspace') return undefined
|
|
2349
|
+
const parts = innerPath(route.inner)
|
|
2350
|
+
const at = parts.indexOf(WORKSPACE_SESSION_SEGMENT)
|
|
2351
|
+
return at >= 0 && at + 1 < parts.length ? parts[at + 1] : undefined
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* Whether a route names the conversation another route names. A workspace prefix is
|
|
2356
|
+
* decoration, so the same conversation reached in either shape counts as arrived.
|
|
2357
|
+
* @param {string} wanted - the fragment handed over.
|
|
2358
|
+
* @param {string} now - the fragment to compare.
|
|
2359
|
+
* @returns {boolean} whether they name the same thing.
|
|
2360
|
+
*/
|
|
2361
|
+
function landedFragment(wanted, now) {
|
|
2362
|
+
if (String(wanted) === String(now)) return true
|
|
2363
|
+
const target = targetSessionOf(wanted)
|
|
2364
|
+
return target !== undefined && target === targetSessionOf(now)
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* Why a handed-over link did not land, in the few words the bar can show. The router's
|
|
2369
|
+
* own report is the source — it knows what is on screen and what it refused — so the
|
|
2370
|
+
* line stays factual instead of guessing a cause.
|
|
2371
|
+
* @param {string} wanted - the fragment handed over.
|
|
2372
|
+
* @returns {string} the line to show in the bar.
|
|
2373
|
+
*/
|
|
2374
|
+
function refusalReason(wanted) {
|
|
2375
|
+
const report = attemptValue(() => window.__dshUrlRouter?.report?.())
|
|
2376
|
+
const target = targetSessionOf(wanted)
|
|
2377
|
+
const name = target === undefined ? String(wanted) : target.slice(0, 18)
|
|
2378
|
+
const canonical = report?.canonical
|
|
2379
|
+
const shown = canonical === undefined || canonical === null
|
|
2380
|
+
? '无'
|
|
2381
|
+
: `${canonical.kind}:${String(canonical.id).slice(0, 8)}`
|
|
2382
|
+
const rows = report?.surfaces?.conversationRows ?? '?'
|
|
2383
|
+
return `打不开 ${name}(当前 ${shown},侧栏 ${rows} 行)`
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Draw the address bar into the desktop window's title bar strip.
|
|
2388
|
+
* @param {object} document - the document to draw into.
|
|
2389
|
+
* @param {Function} subscribe - registers a listener for every canonical write.
|
|
2390
|
+
* @returns {() => void} the disposer.
|
|
2391
|
+
*/
|
|
2392
|
+
function startTitlebarAddress(document, subscribe) {
|
|
2393
|
+
if (document === undefined || document === null) return () => {}
|
|
2394
|
+
const root = document.documentElement
|
|
2395
|
+
// The marker is the shell's own statement that this page has a title bar drawn
|
|
2396
|
+
// inside it. Without it there is no strip, and nothing is drawn.
|
|
2397
|
+
if (typeof root?.getAttribute !== 'function' || root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) === null) return () => {}
|
|
2398
|
+
if (typeof document.createElement !== 'function' || typeof document.body?.appendChild !== 'function') return () => {}
|
|
2399
|
+
let style
|
|
2400
|
+
try {
|
|
2401
|
+
style = document.createElement('style')
|
|
2402
|
+
style.id = TITLEBAR_STYLE_ID
|
|
2403
|
+
style.textContent = TITLEBAR_STYLE_TEXT
|
|
2404
|
+
const parent = document.head ?? document.body
|
|
2405
|
+
if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
|
|
2406
|
+
} catch {
|
|
2407
|
+
style = undefined
|
|
2408
|
+
}
|
|
2409
|
+
const bar = document.createElement('div')
|
|
2410
|
+
bar.id = TITLEBAR_BAR_ID
|
|
2411
|
+
if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-part', TITLEBAR_PART)
|
|
2412
|
+
const input = document.createElement('input')
|
|
2413
|
+
input.type = 'text'
|
|
2414
|
+
input.spellcheck = false
|
|
2415
|
+
input.placeholder = TITLEBAR_PLACEHOLDER
|
|
2416
|
+
if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
|
|
2417
|
+
bar.appendChild(input)
|
|
2418
|
+
/** Start after the shell's menu, stop before its window buttons. */
|
|
2419
|
+
const place = () => {
|
|
2420
|
+
let menuWidth = 0
|
|
2421
|
+
try {
|
|
2422
|
+
const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
|
|
2423
|
+
if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
|
|
2424
|
+
} catch {
|
|
2425
|
+
menuWidth = 0
|
|
2426
|
+
}
|
|
2427
|
+
bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
|
|
2428
|
+
bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
|
|
2429
|
+
}
|
|
2430
|
+
place()
|
|
2431
|
+
/** How the bar looks right now: idle, waiting for a link to land, or refused. */
|
|
2432
|
+
const mark = (state, hint) => {
|
|
2433
|
+
if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-url-router-state', state)
|
|
2434
|
+
if (typeof input.setAttribute === 'function' && typeof input.removeAttribute === 'function') {
|
|
2435
|
+
if (hint === undefined) input.removeAttribute('title')
|
|
2436
|
+
else input.setAttribute('title', hint)
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
/** Show the current fragment, unless the reader is in the middle of typing. */
|
|
2440
|
+
const show = hash => {
|
|
2441
|
+
if (document.activeElement === input) return
|
|
2442
|
+
mark('idle', undefined)
|
|
2443
|
+
input.value = readableFragment(hash)
|
|
2444
|
+
}
|
|
2445
|
+
show(window.location?.hash)
|
|
2446
|
+
/** Whether the view has arrived where the link asked, judged by what is on screen. */
|
|
2447
|
+
const arrived = wanted => {
|
|
2448
|
+
const report = attemptValue(() => window.__dshUrlRouter?.report?.())
|
|
2449
|
+
const canonical = report?.canonical
|
|
2450
|
+
if (canonical === undefined || canonical === null) return false
|
|
2451
|
+
return landedFragment(wanted, routeHash(canonical.kind, canonical.id, canonical.inner))
|
|
2452
|
+
}
|
|
2453
|
+
let settleTimer
|
|
2454
|
+
const stopSettle = () => {
|
|
2455
|
+
if (settleTimer !== undefined) attemptValue(() => window.clearTimeout(settleTimer))
|
|
2456
|
+
settleTimer = undefined
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* A link that never lands has to say so: this window has no console and no address
|
|
2460
|
+
* bar of its own, so a refused link would otherwise look like a dead input.
|
|
2461
|
+
* @param {string} wanted - the fragment handed over.
|
|
2462
|
+
* @param {boolean} retried - whether the plain hash write has been tried as well.
|
|
2463
|
+
*/
|
|
2464
|
+
const settle = (wanted, retried) => {
|
|
2465
|
+
stopSettle()
|
|
2466
|
+
settleTimer = attemptValue(() => window.setTimeout(() => {
|
|
2467
|
+
settleTimer = undefined
|
|
2468
|
+
if (arrived(wanted)) {
|
|
2469
|
+
// Landed: the bar goes back to naming what is on screen, which is the
|
|
2470
|
+
// canonical form of the route the reader typed.
|
|
2471
|
+
mark('idle', undefined)
|
|
2472
|
+
input.value = readableFragment(window.location?.hash)
|
|
2473
|
+
return
|
|
2474
|
+
}
|
|
2475
|
+
// The direct adoption did nothing. Try the plain hash write once — that is the
|
|
2476
|
+
// path a pasted link takes in a browser — and only then report a refusal.
|
|
2477
|
+
if (!retried) {
|
|
2478
|
+
attemptValue(() => {
|
|
2479
|
+
window.location.hash = wanted
|
|
2480
|
+
})
|
|
2481
|
+
settle(wanted, true)
|
|
2482
|
+
return
|
|
2483
|
+
}
|
|
2484
|
+
const why = refusalReason(wanted)
|
|
2485
|
+
mark('refused', why)
|
|
2486
|
+
input.value = why
|
|
2487
|
+
}, TITLEBAR_SETTLE_MS))
|
|
2488
|
+
}
|
|
2489
|
+
const onKeyDown = event => {
|
|
2490
|
+
if (event?.key === 'Escape') {
|
|
2491
|
+
stopSettle()
|
|
2492
|
+
mark('idle', undefined)
|
|
2493
|
+
input.value = readableFragment(window.location?.hash)
|
|
2494
|
+
return
|
|
2495
|
+
}
|
|
2496
|
+
if (event?.key !== 'Enter') return
|
|
2497
|
+
const fragment = titlebarFragmentOf(input.value)
|
|
2498
|
+
if (fragment === undefined) {
|
|
2499
|
+
// Nothing here names a route: leave the address bar saying what is on screen.
|
|
2500
|
+
stopSettle()
|
|
2501
|
+
mark('idle', undefined)
|
|
2502
|
+
input.value = readableFragment(window.location?.hash)
|
|
2503
|
+
return
|
|
2504
|
+
}
|
|
2505
|
+
mark('pending', undefined)
|
|
2506
|
+
// Handed over directly rather than by writing the hash and hoping for a
|
|
2507
|
+
// `hashchange`: that event never fires when the hash already says this, and the
|
|
2508
|
+
// desktop window is not the browser's own page. The router writes the hash itself.
|
|
2509
|
+
driveTypedFragment(fragment)
|
|
2510
|
+
settle(fragment, false)
|
|
2511
|
+
}
|
|
2512
|
+
input.addEventListener('keydown', onKeyDown)
|
|
2513
|
+
document.body.appendChild(bar)
|
|
2514
|
+
const offRoute = subscribe(show)
|
|
2515
|
+
let onResize
|
|
2516
|
+
try {
|
|
2517
|
+
onResize = () => place()
|
|
2518
|
+
window.addEventListener('resize', onResize)
|
|
2519
|
+
} catch {
|
|
2520
|
+
onResize = undefined
|
|
2521
|
+
}
|
|
2522
|
+
return () => {
|
|
2523
|
+
offRoute?.()
|
|
2524
|
+
stopSettle()
|
|
2525
|
+
input.removeEventListener?.('keydown', onKeyDown)
|
|
2526
|
+
if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
|
|
2527
|
+
bar.remove?.()
|
|
2528
|
+
style?.remove?.()
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2056
2532
|
function apply(ctx) {
|
|
2057
2533
|
// Fragment routing: the address bar names the view, and a link opens it.
|
|
2058
2534
|
ctx.effect(() => {
|
|
@@ -2101,6 +2577,12 @@ window.__ModuleLoader__.load({
|
|
|
2101
2577
|
const slots = probed.faces.slots
|
|
2102
2578
|
return startRowLinks(window.document, openSession, slots, selectPanel)
|
|
2103
2579
|
}, 'url-router: row links')
|
|
2580
|
+
// Desktop address bar: only the Windows shell draws a title bar inside the page,
|
|
2581
|
+
// and that strip is page space. No marker, no bar.
|
|
2582
|
+
ctx.effect(() => {
|
|
2583
|
+
if (typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
2584
|
+
return startTitlebarAddress(window.document, subscribeRoute)
|
|
2585
|
+
}, 'url-router: desktop address bar')
|
|
2104
2586
|
}
|
|
2105
2587
|
|
|
2106
2588
|
exports.apply = apply
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-url-router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "DSH 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板 / 设置),整行即真链接,只写 fragment;Windows 桌面版把地址栏画在标题栏里。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/DDDMUC/dsh-url-router.git"
|