dsh-url-router 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +40 -0
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/cordis.patch.yml +23 -0
- package/lib/client.js +1026 -0
- package/lib/index.js +13 -0
- package/package.json +55 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# AGENTS.md — dsh-url-router
|
|
2
|
+
|
|
3
|
+
本仓是一个独立的 DeepSeek Harness Web GUI 插件仓(**只有浏览器半区**)。
|
|
4
|
+
|
|
5
|
+
## 形态约束
|
|
6
|
+
|
|
7
|
+
- **手写 JS,直接进 `lib/`**:没有构建步骤,`lib/index.js`(宿主半区,空操作)与 `lib/client.js`(浏览器半区,交付产物)既是源也是产物。
|
|
8
|
+
- **`lib/client.js` 必须守住模块加载器合同**:`window.__ModuleLoader__.load({ id: 'dsh-url-router', factory })`,工厂返回 `{ apply, inject }`;`inject` 是服务名 `['sessions','workspaces','uiWorkspace','slots']`,包 `dsh.client.inject` 声明的是四个官方客户端模块 id(含 `@deepseek-ai/dsh-client-ui-layout`)。`ctx.layout` 走 `ctx.get('layout')` **可选读取**(组合里可能没有布局包)。
|
|
9
|
+
- **零依赖**:没有 dependencies/devDependencies,也没有遥测与网络请求。
|
|
10
|
+
- **测试用 `node --test`**:`npm test`。测试**直接加载交付产物**(`node:vm` 里假 loader + 假 window + 手动时钟 + 假 panel store/目录),不复制实现、不 mock 模块图;行为变化必须带测试。
|
|
11
|
+
|
|
12
|
+
## 路由契约(改之前先读)
|
|
13
|
+
|
|
14
|
+
- **本插件是地址栏的唯一写者**。任何其它写 fragment 的插件都不得同时启用(`dsh-session-url` 就是这样一个插件)。
|
|
15
|
+
- **段位**:`#/session/<id>`(会话,`uiWorkspace.openSession`)、`#/panel/<panelId>`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。
|
|
16
|
+
- **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
|
|
17
|
+
- **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。
|
|
18
|
+
- **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。
|
|
19
|
+
- 只用 `replaceState`;路径与查询串原样保留。
|
|
20
|
+
|
|
21
|
+
## 行链接契约(改之前先读)
|
|
22
|
+
|
|
23
|
+
- **整行即链接**:`[data-row-key^="session:"]` 的会话行会被挂上**覆盖整行的透明真锚点**(`data-dsh-part="url-router-link"`,作为行首子元素,`href="#/session/<id>"`、`target="_blank"`)。右键=浏览器原生链接菜单,中键/修饰键=原生新标签,**左键单击只 `preventDefault` 后调官方导航**(原地切换、不新增历史记录)。
|
|
24
|
+
- **样式表两条规则**,其中 `:is(button,[role="button"],a,…):not(.dsh-url-router-link)` 的 `:not()` 是**止血点**:去掉它,这条规则会匹配锚点自身(锚点就是 `<a>`),把它压成 0×0 的 relative 元素,右键就再也命中不到(历史事故,别删)。
|
|
25
|
+
- 锚点 href 必须用本插件自己的编码函数 `routeHash('session', id)`,不要另写一套。
|
|
26
|
+
- **找不到 DOM / 行属性不匹配一律静默 no-op**,绝不改官方行、绝不抛错;锚点点击里的导航也要 `try/catch`(官方导航可能抛)。
|
|
27
|
+
- 行选择器或属性一旦改动,必须同步改 `test/client.test.js` 里的假 DOM 用例(假 DOM 已包含 `fakeDom`/`openWithDom`)。
|
|
28
|
+
|
|
29
|
+
- **面板内部页是多段的**:`#/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`。
|
|
30
|
+
- **任务看板走它自己的契约**(由用户单仓 `dsh-task-board` 发布):`#/panel/task-board/task/<任务id>` 与 `#/panel/task-board/new`。反映读 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`;驱动派发同名窗口事件;**表单优先于任务详情**。
|
|
31
|
+
- **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。
|
|
32
|
+
- **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。
|
|
33
|
+
- **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。
|
|
34
|
+
- **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。
|
|
35
|
+
- **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
|
|
36
|
+
- **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
|
|
37
|
+
|
|
38
|
+
## 跨机开发日志
|
|
39
|
+
|
|
40
|
+
改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DDDMUC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-url-router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One URL for every surface: the address bar names what is on screen, and a link opens
|
|
4
|
+
the surface it names. **Browser half only** — no dependencies, no host route, no telemetry.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
#/session/session-2b6e6371-6475-4c44-9f46-a779916a0b56 ← the conversation on screen
|
|
8
|
+
#/panel/plugins ← the Plugins panel
|
|
9
|
+
#/panel/task-board ← the Task Board panel
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Click the Plugins row in the sidebar and the address bar becomes `#/panel/plugins`;
|
|
13
|
+
click a conversation and it becomes `#/session/<id>`. Paste either link into another
|
|
14
|
+
tab (same machine, same `DSH_HOME`) and that surface opens.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
dsh plugin --profile web add dsh-url-router
|
|
20
|
+
# restart `dsh web`, then hard-refresh the browser
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Routes
|
|
24
|
+
|
|
25
|
+
| Route | Surface | Opened by |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `#/session/<id>` | the conversation the main view shows | `uiWorkspace.openSession(id)` |
|
|
28
|
+
| `#/panel/<panelId>` | a main-column panel (`plugins`, `task-board`, `skill-explorer`, `ssh`, …) | `layout.selectPanel(id)` |
|
|
29
|
+
| `#/panel/plugins/<package>` | an installed plugin's page | the panel's own list row control |
|
|
30
|
+
| `#/panel/plugins/<item>` | a built-in plugin's page (`shell`, `agent-loop`, `subagent`, `web-search`) | the panel's own list row control |
|
|
31
|
+
| `#/panel/plugins/<package>/<component>` | one component page inside that plugin's page | the panel's own component row control |
|
|
32
|
+
| `#/panel/task-board/task/<taskId>` | one task's detail on the task board | the task board's own `dsh-taskboard-open-task` window event |
|
|
33
|
+
| `#/panel/task-board/new` | the task board's new-task form | the task board's own `dsh-taskboard-new-task` window event |
|
|
34
|
+
|
|
35
|
+
Route kinds are owned by this plugin; a kind it does not know (`#/settings/…`) is
|
|
36
|
+
**left alone** with a single diagnostic, so another router can own it later.
|
|
37
|
+
|
|
38
|
+
A panel's inner pages are the panel's own vocabulary, so this plugin never invents one:
|
|
39
|
+
the plugin manager publishes `data-plugin-detail` / `data-plugin-item-detail` /
|
|
40
|
+
`data-plugin-row-detail`, and the task board publishes `data-dsh-taskboard-open-task` /
|
|
41
|
+
`data-dsh-taskboard-new-task` while asking for a task on a window event of the same name.
|
|
42
|
+
|
|
43
|
+
## Row links
|
|
44
|
+
|
|
45
|
+
Every conversation row in the sidebar is exposed as a **real link** — a transparent
|
|
46
|
+
`<a href="#/session/<id>" target="_blank">` covering the row, so the browser's own link
|
|
47
|
+
behavior applies with no custom menu:
|
|
48
|
+
|
|
49
|
+
- **right-click** → the native menu (open link in new tab / copy link / save link as)
|
|
50
|
+
- **middle click, Cmd/Ctrl+click** → a new tab, natively
|
|
51
|
+
- **plain left click** → opens the conversation *here* (`preventDefault` + the official
|
|
52
|
+
navigation), so the address bar keeps using `replaceState` and no history entry is added
|
|
53
|
+
- the row's own controls (⋯, buttons) are lifted above the overlay and keep working
|
|
54
|
+
|
|
55
|
+
The same applies to the **main-panel rows** (Plugins, Task Board, …): each becomes
|
|
56
|
+
`#/panel/<id>`. A panel row carries no identity in the DOM, so its id comes from the panel
|
|
57
|
+
registry (`ctx.slots.entries('sidebar.panellist')`), matched by accessible name and aligned
|
|
58
|
+
by registration order; a row that cannot be matched is left untouched.
|
|
59
|
+
|
|
60
|
+
It supersedes the earlier `dsh-session-url` plugin: install one, not both (both write the
|
|
61
|
+
fragment). The link shape `#/session/<id>` is identical, so existing links keep working.
|
|
62
|
+
|
|
63
|
+
## Rules it keeps
|
|
64
|
+
|
|
65
|
+
- **Fragment only.** Path and query string are never touched (a launch `?token=…` survives).
|
|
66
|
+
- **One writer.** This plugin owns the fragment; run it *instead of* any other plugin
|
|
67
|
+
that writes the address bar (`dsh-session-url` mirrors conversations into the same
|
|
68
|
+
fragment — the two would overwrite each other).
|
|
69
|
+
- **`replaceState` only**, so browsing panels does not fill the Back stack.
|
|
70
|
+
- **Scopes are declared, not guessed.** A main panel is application-level (it lists this
|
|
71
|
+
machine's plugins, not a conversation), so its link carries no session id.
|
|
72
|
+
- **A link stays authoritative until it lands** (at most one boot, 8 s): once the named
|
|
73
|
+
surface is on screen the link stops owning the URL, so walking deeper inside a panel
|
|
74
|
+
updates the address bar instead of being pulled back to the link.
|
|
75
|
+
- **A link wins for one boot.** Both owners restore their own remembered state while the
|
|
76
|
+
page loads, so a linked surface stays authoritative for 8 s; after that the view owns
|
|
77
|
+
the URL again.
|
|
78
|
+
- **Never throws.** Refused navigations and broken services are contained, reported once,
|
|
79
|
+
and fall back to the view actually on screen — never a blank page.
|
|
80
|
+
|
|
81
|
+
## Limits
|
|
82
|
+
|
|
83
|
+
- Depends on the official client faces `ctx.layout.selectPanel` / `ctx.layout.panelInfo`
|
|
84
|
+
and the session/workspace controllers. If their shape changes, the plugin disables
|
|
85
|
+
itself with one diagnostic rather than touching official behavior.
|
|
86
|
+
- Row links depend on the official row markup (`div[data-row-key="session:<id>"]`); if
|
|
87
|
+
that changes the plugin degrades to doing nothing rather than touching official rows.
|
|
88
|
+
- Settings sections are **not** routed yet: the settings package exposes no
|
|
89
|
+
"open section" seam (they are keyed list slots). Right-hand panes are openable
|
|
90
|
+
(`ISidebarRight.openTab(kind)`) but not yet routed.
|
|
91
|
+
- An unregistered panel id is refused by the layout by *throwing*; the link is dropped at
|
|
92
|
+
the end of its window with one diagnostic. A panel that registers within the window is
|
|
93
|
+
still reached.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
# dsh-url-router(中文)
|
|
98
|
+
|
|
99
|
+
**一个界面,一条链接。** 地址栏永远描述屏幕上的东西,链接打开它命名的界面。
|
|
100
|
+
|
|
101
|
+
- 点侧栏「插件」→ 地址栏变成 `#/panel/plugins`;点回某个对话 → 变回 `#/session/<id>`。
|
|
102
|
+
- 把链接粘到另一个标签页(同机、同 `DSH_HOME`)就能直接打开那个界面。
|
|
103
|
+
- 只写 fragment,路径与查询串原样保留;只用 `replaceState`,不往历史里塞垃圾。
|
|
104
|
+
- **它必须是地址栏的唯一写者**:不要和 `dsh-session-url` 同时启用(两者会互相覆盖)。
|
|
105
|
+
- 未知的段位(如 `#/settings/…`)一律**不碰**,只留一条诊断,留给将来的路由器。
|
|
106
|
+
- 目标打不开(未注册的面板、归档的会话)→ 到期丢弃 + 一条诊断 + 回落到屏幕上真实的界面,**绝不白屏、绝不抛错**。
|
|
107
|
+
|
|
108
|
+
## 安装
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
dsh plugin --profile web add dsh-url-router
|
|
112
|
+
# 重启 dsh web,之后硬刷新浏览器
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 已知限制
|
|
116
|
+
|
|
117
|
+
- 依赖官方 `ctx.layout.selectPanel` / `panelInfo` 与会话、工作区控制器;形态变了就自我禁用(一条诊断),不改官方行为。
|
|
118
|
+
- **任务看板内部页已纳入**:`#/panel/task-board/task/<任务id>`(任务详情弹层)、`#/panel/task-board/new`(新建任务表单)。这两条靠任务看板自己发布的契约(`data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task` + 同名窗口事件),本插件不猜它的弹层。
|
|
119
|
+
- **设置页分区暂未纳入**(设置包没有"打开某分区"的接缝;它们是 keyed list slot)。
|
|
120
|
+
- 右栏 pane 能打开(`ISidebarRight.openTab(kind)`),但尚未纳入路由。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# dsh-url-router bundle patch: inserts the plugin row into the profile roster.
|
|
2
|
+
#
|
|
3
|
+
# The browser half (exports "./client") owns the URL fragment: it mirrors the view
|
|
4
|
+
# on screen into the address bar (`#/session/<id>`, `#/panel/<panelId>`) and opens
|
|
5
|
+
# the surface a link names. There is no host behavior: no route, no tool, no service.
|
|
6
|
+
#
|
|
7
|
+
# Because this plugin is the ONLY writer of the fragment, do not run it alongside
|
|
8
|
+
# another plugin that writes the fragment (for example dsh-session-url): the two
|
|
9
|
+
# would overwrite each other's URL on every notification.
|
|
10
|
+
#
|
|
11
|
+
# The entry name is the BARE package name on purpose: the client-module scanner
|
|
12
|
+
# treats it as a package specifier and resolves `<name>/package.json`, so a
|
|
13
|
+
# subpath entry would be cached as "not a client package" and the browser half
|
|
14
|
+
# would silently never load.
|
|
15
|
+
#
|
|
16
|
+
# A bundle-row change is a profile composition change: enabling, disabling or
|
|
17
|
+
# updating this plugin takes effect on the next page load for the browser half,
|
|
18
|
+
# and after a host restart for the row itself (unless the profile sets
|
|
19
|
+
# `patchReload: live`, in which case the row hot-loads and a page refresh is
|
|
20
|
+
# enough).
|
|
21
|
+
- insert:
|
|
22
|
+
- id: dsh-url-router
|
|
23
|
+
name: 'dsh-url-router'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-url-router · browser half.
|
|
3
|
+
*
|
|
4
|
+
* One URL for every surface: the address bar names what is on screen, and a link
|
|
5
|
+
* opens the surface it names. This plugin is the **single owner of the URL
|
|
6
|
+
* fragment** — other plugins register routes with it instead of writing the
|
|
7
|
+
* address bar themselves, so two plugins can never fight over the URL.
|
|
8
|
+
*
|
|
9
|
+
* Surfaces it owns today (L1, "which view is shown"):
|
|
10
|
+
* - `#/session/<id>` — the conversation the main view shows
|
|
11
|
+
* - `#/panel/<panelId>` — a main-column panel (plugins, skills, SSH, task board, …)
|
|
12
|
+
*
|
|
13
|
+
* Why a router rather than one plugin per surface: the official GUI has no URL
|
|
14
|
+
* concept of its own (no route, no `?session=`, no `#/session/…` anywhere in the
|
|
15
|
+
* official client bundles), yet several community plugins write the address bar.
|
|
16
|
+
* Whoever writes it must be one, or the links fight.
|
|
17
|
+
*
|
|
18
|
+
* Scopes are declared by the view, not guessed: a main panel is an
|
|
19
|
+
* application-level surface — it lists this machine's plugins, not a conversation
|
|
20
|
+
* — so its link carries no session id. Only session-scoped views name a session.
|
|
21
|
+
*
|
|
22
|
+
* Facts this router relies on, read from the official client packages:
|
|
23
|
+
* - `ctx.layout.selectPanel(panelId | null)` selects the active main panel.
|
|
24
|
+
* - `ctx.layout.panelInfo` is a store: `getSnapshot().activePanelId` + `subscribe()`.
|
|
25
|
+
* - `ctx.uiWorkspace.openSession(id)` navigates; `ctx.sessions.list` lists.
|
|
26
|
+
* `ctx.layout` is read through `ctx.get('layout')` (the layout package may be
|
|
27
|
+
* absent from a composition); the three session faces are hard dependencies.
|
|
28
|
+
*
|
|
29
|
+
* Failure policy: unknown route kinds and unreachable targets are ignored with ONE
|
|
30
|
+
* diagnostic each — never an exception, never a blank screen. A refused target
|
|
31
|
+
* falls back to the view the address bar itself would have described.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-url-router
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
window.__ModuleLoader__.load({
|
|
37
|
+
id: 'dsh-url-router',
|
|
38
|
+
factory: () => {
|
|
39
|
+
const module = { exports: {} }
|
|
40
|
+
const exports = module.exports
|
|
41
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------
|
|
44
|
+
// main-view session: resolve the Session the main view currently shows.
|
|
45
|
+
// Reads the catalog's rows rather than a per-id retain-info source, so this
|
|
46
|
+
// neither allocates observers nor opens history, and a subscription to the
|
|
47
|
+
// list still fires when the selection moves.
|
|
48
|
+
// ---------------------------------------------------------------------
|
|
49
|
+
function mainViewSessionId(byId) {
|
|
50
|
+
if (byId === undefined || byId === null) return undefined
|
|
51
|
+
for (const row of Object.values(byId)) {
|
|
52
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------
|
|
58
|
+
// route codec: the single mapping between a location fragment and a surface.
|
|
59
|
+
//
|
|
60
|
+
// Canonical form is `#/<kind>/<encoded id>`. A plural kind is accepted on
|
|
61
|
+
// input (a retyped or hand-written link) and normalised to the canonical
|
|
62
|
+
// singular. Unknown kinds are reported so the caller stays silent about them:
|
|
63
|
+
// another router's route may legitimately be pasted in.
|
|
64
|
+
// ---------------------------------------------------------------------
|
|
65
|
+
/** Route kind aliases accepted on input. */
|
|
66
|
+
const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel' }
|
|
67
|
+
/** Canonical fragment shape. */
|
|
68
|
+
const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)\/(.+)$/
|
|
69
|
+
/** How often an unsatisfied link is retried while it is authoritative. */
|
|
70
|
+
const LINK_RETRY_MS = 500
|
|
71
|
+
/** How many times an inner page may be requested while its link is authoritative. */
|
|
72
|
+
const INNER_ATTEMPT_LIMIT = 40
|
|
73
|
+
/** How long a linked surface stays authoritative over the view owner's own restore. */
|
|
74
|
+
const LINK_ENFORCE_WINDOW_MS = 8000
|
|
75
|
+
/** Panel id of the task board, whose open-task contract this router drives. */
|
|
76
|
+
const TASK_BOARD_PANEL_ID = 'task-board'
|
|
77
|
+
/** Attribute the task board publishes the open task on. */
|
|
78
|
+
const TASK_BOARD_OPEN_ATTRIBUTE = 'data-dsh-taskboard-open-task'
|
|
79
|
+
/** Window event that asks the task board to open a task. */
|
|
80
|
+
const TASK_BOARD_OPEN_EVENT = 'dsh-taskboard-open-task'
|
|
81
|
+
/** Attribute the task board publishes while its new-task form is open. */
|
|
82
|
+
const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
|
|
83
|
+
/** Window event that asks the task board for its new-task form. */
|
|
84
|
+
const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
|
|
85
|
+
/** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
|
|
86
|
+
const PLUGINS_PANEL_ID = 'plugins'
|
|
87
|
+
/** Log prefix for every diagnostic. */
|
|
88
|
+
const TAG = '[url-router]'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a route out of a location fragment.
|
|
92
|
+
* @param {string} hash - the raw `window.location.hash`, including the leading `#`.
|
|
93
|
+
* @returns {{kind: string, id: string}|{unknown: string}|undefined} the route, an unknown-kind report, or undefined for "no route here".
|
|
94
|
+
*/
|
|
95
|
+
function decodePart(raw) {
|
|
96
|
+
try {
|
|
97
|
+
return decodeURIComponent(raw)
|
|
98
|
+
} catch {
|
|
99
|
+
return raw
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseRoute(hash) {
|
|
104
|
+
const match = ROUTE_PATTERN.exec(hash)
|
|
105
|
+
if (match === null) return undefined
|
|
106
|
+
const segments = match[2].replace(/\/+$/, '').split('/')
|
|
107
|
+
const head = segments.shift() ?? ''
|
|
108
|
+
if (head === '') return undefined
|
|
109
|
+
const id = decodePart(head).trim()
|
|
110
|
+
if (id === '') return undefined
|
|
111
|
+
const kind = ROUTE_ALIASES[match[1]]
|
|
112
|
+
if (kind === undefined) return { unknown: match[1] }
|
|
113
|
+
// A panel may have an inner page (a plugin's detail view, for instance): the
|
|
114
|
+
// rest of the path names it, per panel.
|
|
115
|
+
const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
|
|
116
|
+
return { kind, id, inner: inner.length === 0 ? undefined : inner }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the canonical fragment for a route.
|
|
121
|
+
* @param {string} kind - route kind.
|
|
122
|
+
* @param {string} id - route identity.
|
|
123
|
+
* @returns {string} the `#/<kind>/<encoded id>` fragment.
|
|
124
|
+
*/
|
|
125
|
+
function routeHash(kind, id, inner) {
|
|
126
|
+
const base = `#/${kind}/${encodeURIComponent(id)}`
|
|
127
|
+
if (inner === undefined || inner === '') return base
|
|
128
|
+
const segments = innerPath(inner)
|
|
129
|
+
return segments.length === 0 ? base : `${base}/${segments.map(encodeURIComponent).join('/')}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalise an inner path into its segments.
|
|
134
|
+
*
|
|
135
|
+
* A panel's inner page may nest (`<package>/<component>`), and each segment is
|
|
136
|
+
* encoded on its own, so a raw `/` in the path is always a separator while a `/`
|
|
137
|
+
* inside a name survives as `%2F`.
|
|
138
|
+
* @param {string|Array<string>} value - the inner path or its segments.
|
|
139
|
+
* @returns {Array<string>} the segments, empty when there is no inner page.
|
|
140
|
+
*/
|
|
141
|
+
/**
|
|
142
|
+
* Split a row hook value into the package it belongs to and the row id.
|
|
143
|
+
*
|
|
144
|
+
* Two spellings exist in the panel's DOM: a page-level `<package>#<rowId>` and a
|
|
145
|
+
* row-level `<kind>:<rowId>`. Only the row id is routed; the package is used when
|
|
146
|
+
* the page reports one.
|
|
147
|
+
* @param {unknown} value - the raw attribute value.
|
|
148
|
+
* @returns {{packageName: string|undefined, rowId: string}|undefined} the parts, or undefined when there is nothing usable.
|
|
149
|
+
*/
|
|
150
|
+
function splitRowHook(value) {
|
|
151
|
+
if (typeof value !== 'string' || value.trim() === '') return undefined
|
|
152
|
+
const hash = value.indexOf('#')
|
|
153
|
+
if (hash !== -1) {
|
|
154
|
+
const rowId = value.slice(hash + 1).trim()
|
|
155
|
+
if (rowId === '') return undefined
|
|
156
|
+
const packageName = value.slice(0, hash).trim()
|
|
157
|
+
return { packageName: packageName === '' ? undefined : packageName, rowId }
|
|
158
|
+
}
|
|
159
|
+
const colon = value.lastIndexOf(':')
|
|
160
|
+
const rowId = (colon === -1 ? value : value.slice(colon + 1)).trim()
|
|
161
|
+
return rowId === '' ? undefined : { packageName: undefined, rowId }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function innerPath(value) {
|
|
165
|
+
const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
|
|
166
|
+
return parts.map(part => String(part).trim()).filter(part => part !== '')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build the address-bar URL for one view state, preserving path and query.
|
|
171
|
+
* @param {string} pathname - current `location.pathname` (the mount path is preserved).
|
|
172
|
+
* @param {string} search - current `location.search` (a launch token, when present).
|
|
173
|
+
* @param {{kind: string, id: string}|undefined} route - the view to name, or undefined for "no view named".
|
|
174
|
+
* @returns {string} the URL to write.
|
|
175
|
+
*/
|
|
176
|
+
function routeUrl(pathname, search, route) {
|
|
177
|
+
const base = `${pathname}${search}`
|
|
178
|
+
return route === undefined ? base : `${base}${routeHash(route.kind, route.id, route.inner)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read one data attribute anywhere in the document.
|
|
183
|
+
* @param {string} selector - the attribute selector to find the owner by.
|
|
184
|
+
* @param {string} name - the attribute to read.
|
|
185
|
+
* @returns {string|undefined} the value, when it is a non-empty string.
|
|
186
|
+
*/
|
|
187
|
+
function attributeOf(selector, name) {
|
|
188
|
+
const node = window.document?.querySelector?.(selector)
|
|
189
|
+
const value = typeof node?.getAttribute === 'function' ? node.getAttribute(name) : null
|
|
190
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Serialise a route for cheap comparison. */
|
|
194
|
+
const routeKey = route =>
|
|
195
|
+
route === undefined ? 'none' : `${route.kind}:${route.id}:${innerPath(route.inner).map(part => encodeURIComponent(part)).join('/')}`
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------
|
|
198
|
+
// the router: two directions, one writer.
|
|
199
|
+
//
|
|
200
|
+
// - Fragment to view: a link names a surface, so loading (or pasting)
|
|
201
|
+
// `#/panel/plugins` selects that panel and `#/session/<id>` opens that
|
|
202
|
+
// conversation.
|
|
203
|
+
// - View to fragment: the address bar always names what is on screen — a
|
|
204
|
+
// panel when one is selected, otherwise the conversation the main view
|
|
205
|
+
// shows.
|
|
206
|
+
//
|
|
207
|
+
// A link needs an enforcement window because both owners restore their own
|
|
208
|
+
// remembered state while loading, which can land after the link was read.
|
|
209
|
+
// Within the window the link wins; after it the view owns the URL again, so a
|
|
210
|
+
// click during boot is never fought for longer than a boot.
|
|
211
|
+
//
|
|
212
|
+
// The state machine is host-injected (panel store, catalog, navigation,
|
|
213
|
+
// location, clock, timer) and every notification carries its source, so it is
|
|
214
|
+
// deterministic in tests and a claim can be retried on the notification that
|
|
215
|
+
// actually matters (a panel registering late) without retrying on unrelated
|
|
216
|
+
// catalog churn.
|
|
217
|
+
// ---------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Start routing the fragment.
|
|
221
|
+
* @param {object} host - the injected faces.
|
|
222
|
+
* @returns {() => void} the disposer removing every subscription and timer.
|
|
223
|
+
*/
|
|
224
|
+
function startUrlRouting(host) {
|
|
225
|
+
/** The route the address bar claims, with its enforcement deadline. */
|
|
226
|
+
let linked
|
|
227
|
+
/** Whether the claimed route was already handed to its view owner. */
|
|
228
|
+
let claimed = false
|
|
229
|
+
/** The package segment of the deepest panel page the address bar carried. */
|
|
230
|
+
let panelScope
|
|
231
|
+
/** How many times the linked panel was selected (bounded, see below). */
|
|
232
|
+
let panelAttempts = 0
|
|
233
|
+
/** How many times a panel link's inner page was requested (bounded, see below). */
|
|
234
|
+
let innerAttempts = 0
|
|
235
|
+
/** The view state observed on the previous notification. */
|
|
236
|
+
let seen = 'none'
|
|
237
|
+
let cancelTimer
|
|
238
|
+
let retryTimer
|
|
239
|
+
let disposed = false
|
|
240
|
+
|
|
241
|
+
const dropTimer = () => {
|
|
242
|
+
cancelTimer?.()
|
|
243
|
+
cancelTimer = undefined
|
|
244
|
+
}
|
|
245
|
+
const dropLink = () => {
|
|
246
|
+
dropRetry()
|
|
247
|
+
linked = undefined
|
|
248
|
+
claimed = false
|
|
249
|
+
panelAttempts = 0
|
|
250
|
+
innerAttempts = 0
|
|
251
|
+
dropTimer()
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The route the current view deserves: a selected panel outranks the
|
|
255
|
+
* conversation behind it, because the panel is what fills the screen.
|
|
256
|
+
*/
|
|
257
|
+
const canonical = () => {
|
|
258
|
+
const panelId = host.activePanelId()
|
|
259
|
+
if (panelId !== undefined && panelId !== null) {
|
|
260
|
+
const id = String(panelId)
|
|
261
|
+
const page = host.panelPage(id, panelScope)
|
|
262
|
+
if (page === undefined) return { kind: 'panel', id, inner: undefined }
|
|
263
|
+
const segments = innerPath(page)
|
|
264
|
+
// Remember the package a page was opened under, so a bare component page (which
|
|
265
|
+
// reports only its row) can still be named fully.
|
|
266
|
+
if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
|
|
267
|
+
return { kind: 'panel', id, inner: segments }
|
|
268
|
+
}
|
|
269
|
+
const session = host.mainViewSessionId()
|
|
270
|
+
if (session !== undefined && session !== null) return { kind: 'session', id: String(session) }
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
const sameRoute = (left, right) => routeKey(left) === routeKey(right)
|
|
274
|
+
/** Write the fragment for the current view, leaving path and query untouched. */
|
|
275
|
+
const reflect = route => {
|
|
276
|
+
const { pathname, search, hash } = host.readLocation()
|
|
277
|
+
// Compare against the fragment, not the whole URL: the hash never carries the
|
|
278
|
+
// path or the launch query, and comparing them made every call look like a change.
|
|
279
|
+
const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
|
|
280
|
+
if (hash === wantedHash) return
|
|
281
|
+
// Nothing to name and nothing named: leave the address bar exactly as it is
|
|
282
|
+
// (this also leaves a foreign fragment alone, which the caller never reaches).
|
|
283
|
+
if (route === undefined && hash === '') return
|
|
284
|
+
host.replaceUrl(routeUrl(pathname, search, route))
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Re-read the view state and move towards the state the address bar describes.
|
|
288
|
+
* @param {string} source - what notified: `boot`, `panel`, `catalog`, `hash`, or `timer`.
|
|
289
|
+
*/
|
|
290
|
+
const reconcile = (source = 'boot') => {
|
|
291
|
+
if (disposed) return
|
|
292
|
+
const current = canonical()
|
|
293
|
+
const currentKey = routeKey(current)
|
|
294
|
+
const moved = currentKey !== seen
|
|
295
|
+
seen = currentKey
|
|
296
|
+
if (linked === undefined) {
|
|
297
|
+
reflect(current)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (sameRoute(current, linked)) {
|
|
301
|
+
dropRetry()
|
|
302
|
+
// The link has landed: it stops being authoritative right here, so the user
|
|
303
|
+
// keeps owning the address bar afterwards (walking deeper inside a panel
|
|
304
|
+
// updates the URL instead of being pulled back to the link).
|
|
305
|
+
dropLink()
|
|
306
|
+
reflect(current)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (host.now() >= linked.deadline) {
|
|
310
|
+
const hash = routeHash(linked.kind, linked.id, linked.inner)
|
|
311
|
+
host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
|
|
312
|
+
dropLink()
|
|
313
|
+
reflect(canonical())
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (linked.kind === 'panel') {
|
|
317
|
+
// Panels are selected by id and report back through the panel store; an id
|
|
318
|
+
// nobody registered never becomes active, so the claim is retried when the
|
|
319
|
+
// panel store moves (a late-loading plugin), not on every catalog tick.
|
|
320
|
+
// `current` may be undefined when nothing is on screen yet; reading through it
|
|
321
|
+
// unconditionally would abort the whole step inside the guard.
|
|
322
|
+
const panelShown = current !== undefined && current.kind === 'panel' && current.id === linked.id
|
|
323
|
+
if (!panelShown) {
|
|
324
|
+
// Selecting a panel can be refused or swallowed while the shell is still
|
|
325
|
+
// settling, so the request is retried (on later notifications and on a
|
|
326
|
+
// timer) with a bound.
|
|
327
|
+
if (source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
328
|
+
panelAttempts += 1
|
|
329
|
+
// A panel switch is a navigation of its own: abort whatever navigation is
|
|
330
|
+
// in flight first (the layout exposes exactly that pair), otherwise the
|
|
331
|
+
// selection can be swallowed by the boot restore.
|
|
332
|
+
host.beginNavigation()
|
|
333
|
+
host.selectPanel(linked.id)
|
|
334
|
+
}
|
|
335
|
+
armRetry()
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
// The panel is on screen; if the link names an inner page, drive it and let
|
|
339
|
+
// the DOM observer report back (the panel owns how its pages open).
|
|
340
|
+
// An inner page is driven through the panel's own control, which only exists
|
|
341
|
+
// once that panel has rendered: the request is retried on every later
|
|
342
|
+
// notification (not on the hash read itself, to avoid a double request),
|
|
343
|
+
// with a bound so an unopenable page cannot turn into a click loop.
|
|
344
|
+
if (linked.inner !== undefined && routeKey(current) !== routeKey(linked) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
345
|
+
innerAttempts += 1
|
|
346
|
+
host.openPanelPage(linked.id, linked.inner)
|
|
347
|
+
}
|
|
348
|
+
armRetry()
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
// A session link: refuse immediately when the conversation cannot be opened at
|
|
352
|
+
// all, otherwise hand the navigation over once and wait for the view to land.
|
|
353
|
+
if (host.isArchived(linked.id)) {
|
|
354
|
+
host.warn(`${TAG} ${routeHash(linked.kind, linked.id)} is archived; the link was ignored`)
|
|
355
|
+
dropLink()
|
|
356
|
+
reflect(canonical())
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
if ((!claimed || moved) && host.isOpenable(linked.id)) {
|
|
360
|
+
claimed = true
|
|
361
|
+
host.openSession(linked.id)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Adopt the surface the address bar names, if it names one. */
|
|
365
|
+
const readLink = () => {
|
|
366
|
+
if (disposed) return
|
|
367
|
+
const parsed = parseRoute(host.readLocation().hash)
|
|
368
|
+
if (parsed === undefined) {
|
|
369
|
+
dropLink()
|
|
370
|
+
reconcile('hash')
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (parsed.unknown !== undefined) {
|
|
374
|
+
// Another router's route (or a typo): leave the address bar alone and say so
|
|
375
|
+
// once, so a router owning more kinds can take over later.
|
|
376
|
+
if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
|
|
380
|
+
linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
|
|
381
|
+
if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
|
|
382
|
+
claimed = false
|
|
383
|
+
panelAttempts = 0
|
|
384
|
+
innerAttempts = 0
|
|
385
|
+
seen = 'none'
|
|
386
|
+
}
|
|
387
|
+
dropTimer()
|
|
388
|
+
cancelTimer = host.setTimer(() => {
|
|
389
|
+
cancelTimer = undefined
|
|
390
|
+
// Fires from the browser, outside any notification: guard it too.
|
|
391
|
+
guard(() => reconcile('timer'))
|
|
392
|
+
}, LINK_ENFORCE_WINDOW_MS)
|
|
393
|
+
reconcile('boot')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Whether an internal error was already reported (one diagnostic per instance). */
|
|
397
|
+
let crashed = false
|
|
398
|
+
/**
|
|
399
|
+
* Run a machine step so that nothing it meets can escape into the service that
|
|
400
|
+
* notified us: a broken face or an unexpected value must not take the page down.
|
|
401
|
+
*/
|
|
402
|
+
const guard = step => {
|
|
403
|
+
try {
|
|
404
|
+
step()
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (crashed) return
|
|
407
|
+
crashed = true
|
|
408
|
+
host.warn(`${TAG} internal error contained: ${error?.message ?? String(error)}`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const safeReconcile = source => guard(() => reconcile(source))
|
|
412
|
+
/**
|
|
413
|
+
* Keep retrying an unsatisfied link on a timer.
|
|
414
|
+
*
|
|
415
|
+
* Notifications alone are not enough: after boot the shell can go quiet, so a claim
|
|
416
|
+
* that was refused once (a panel registered a moment later, a control not rendered
|
|
417
|
+
* yet) would never be retried and the link would sit there doing nothing. The timer
|
|
418
|
+
* stops as soon as the link lands, is dropped, or its window expires.
|
|
419
|
+
*/
|
|
420
|
+
const armRetry = () => {
|
|
421
|
+
if (disposed || linked === undefined || retryTimer !== undefined) return
|
|
422
|
+
retryTimer = host.setTimer(() => {
|
|
423
|
+
retryTimer = undefined
|
|
424
|
+
safeReconcile('retry')
|
|
425
|
+
}, LINK_RETRY_MS)
|
|
426
|
+
}
|
|
427
|
+
const dropRetry = () => {
|
|
428
|
+
retryTimer?.()
|
|
429
|
+
retryTimer = undefined
|
|
430
|
+
}
|
|
431
|
+
const safeReadLink = () => guard(readLink)
|
|
432
|
+
const offPanel = host.onPanelChange(() => safeReconcile('panel'))
|
|
433
|
+
const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
|
|
434
|
+
const offDom = host.onDomChange(() => safeReconcile('dom'))
|
|
435
|
+
const offHashChange = host.onHashChange(() => safeReadLink())
|
|
436
|
+
safeReadLink()
|
|
437
|
+
return () => {
|
|
438
|
+
disposed = true
|
|
439
|
+
dropTimer()
|
|
440
|
+
dropRetry()
|
|
441
|
+
offPanel()
|
|
442
|
+
offCatalog()
|
|
443
|
+
offDom()
|
|
444
|
+
offHashChange()
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------
|
|
449
|
+
// row links: every conversation row in the sidebar becomes a real anchor.
|
|
450
|
+
//
|
|
451
|
+
// A real <a href="#/session/<id>" target="_blank"> is what makes the link a
|
|
452
|
+
// first-class thing instead of an address-bar convention: left click and
|
|
453
|
+
// middle click open the conversation in a new tab, and the browser's own
|
|
454
|
+
// context menu offers "open link in new tab", "copy link", "save link as" —
|
|
455
|
+
// no custom menu to maintain.
|
|
456
|
+
//
|
|
457
|
+
// The row's identity rides its `data-row-key="session:<id>"` attribute (the
|
|
458
|
+
// official sidebar list is virtualized, so rows come and go; a
|
|
459
|
+
// MutationObserver re-scans). Anything unexpected — no document, no rows, no
|
|
460
|
+
// observer — degrades to doing nothing at all.
|
|
461
|
+
// ---------------------------------------------------------------------
|
|
462
|
+
/** Row attribute carrying the conversation identity. */
|
|
463
|
+
const ROW_KEY_ATTRIBUTE = 'data-row-key'
|
|
464
|
+
/** Prefix of that attribute's value for a conversation row. */
|
|
465
|
+
const ROW_KEY_PREFIX = 'session:'
|
|
466
|
+
/** Rows this plugin looks at. */
|
|
467
|
+
const ROW_SELECTOR = `[${ROW_KEY_ATTRIBUTE}^="${ROW_KEY_PREFIX}"]`
|
|
468
|
+
/** Marks the anchor this plugin owns inside a row. */
|
|
469
|
+
const ANCHOR_PART = 'url-router-link'
|
|
470
|
+
/** Id of the stylesheet this plugin injects. */
|
|
471
|
+
const STYLE_ID = 'dsh-url-router-style'
|
|
472
|
+
/** Marks a row whose positioning context this plugin had to establish. */
|
|
473
|
+
const HOST_ATTRIBUTE = 'data-dsh-url-router-host'
|
|
474
|
+
/** Tooltip naming both the click and the right-click behaviour. */
|
|
475
|
+
const LINK_TITLE = '在新标签页中打开 · 右键可复制链接 / Open in a new tab · right-click to copy the link'
|
|
476
|
+
/** One stylesheet for every anchor; the icon stays invisible until the row is hovered. */
|
|
477
|
+
const STYLE_TEXT = [
|
|
478
|
+
// The anchor covers the whole row, so a right-click anywhere on the row
|
|
479
|
+
// reaches the browser's link menu exactly like any other link. It is
|
|
480
|
+
// transparent, sits under everything (z-index 0), and the row's own controls
|
|
481
|
+
// are lifted above it so their clicks keep working.
|
|
482
|
+
// z-index 3 keeps the overlay above the row's own hover mask; the row's
|
|
483
|
+
// controls are lifted to 4. The :not() is load-bearing: without it this
|
|
484
|
+
// rule also matches THE OVERLAY ITSELF (it is an <a>), turns it into a
|
|
485
|
+
// zero-size relative element, and the right-click never reaches it.
|
|
486
|
+
'.dsh-url-router-link{position:absolute;inset:0;z-index:3;display:block;border-radius:inherit;cursor:pointer;text-decoration:none}',
|
|
487
|
+
`[${HOST_ATTRIBUTE}] :is(button,[role="button"],a,select,input,textarea):not(.dsh-url-router-link){position:relative;z-index:4}`,
|
|
488
|
+
].join('')
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Read the conversation identity out of one row.
|
|
492
|
+
* @param {object} row - a candidate row element.
|
|
493
|
+
* @returns {string|undefined} the session id, or undefined when the row names none.
|
|
494
|
+
*/
|
|
495
|
+
function sessionIdOfRow(row) {
|
|
496
|
+
if (row === null || row === undefined || typeof row.getAttribute !== 'function') return undefined
|
|
497
|
+
const key = row.getAttribute(ROW_KEY_ATTRIBUTE)
|
|
498
|
+
if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) return undefined
|
|
499
|
+
const id = key.slice(ROW_KEY_PREFIX.length).trim()
|
|
500
|
+
return id === '' ? undefined : id
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Build the row-wide anchor for one conversation.
|
|
505
|
+
*
|
|
506
|
+
* It covers the row, so a right-click anywhere reaches the browser's link menu
|
|
507
|
+
* (open in new tab / copy link / save link as) and a middle or modified click
|
|
508
|
+
* opens a new tab natively. A plain left click opens the conversation HERE, by
|
|
509
|
+
* calling the official navigation with the same id the row would use: the
|
|
510
|
+
* address bar therefore keeps being written with replaceState instead of
|
|
511
|
+
* gaining a history entry per click, and the behaviour does not depend on the
|
|
512
|
+
* row's own hit testing.
|
|
513
|
+
* @param {object} document - the document to create the node in.
|
|
514
|
+
* @param {string} id - session identity the link names.
|
|
515
|
+
* @returns {object} the anchor node.
|
|
516
|
+
*/
|
|
517
|
+
function rowAnchor(document, href, open) {
|
|
518
|
+
const anchor = document.createElement('a')
|
|
519
|
+
anchor.setAttribute('href', href)
|
|
520
|
+
anchor.setAttribute('target', '_blank')
|
|
521
|
+
anchor.setAttribute('rel', 'noreferrer')
|
|
522
|
+
anchor.setAttribute('title', LINK_TITLE)
|
|
523
|
+
anchor.setAttribute('aria-label', LINK_TITLE)
|
|
524
|
+
anchor.setAttribute('data-dsh-plugin', 'dsh-url-router')
|
|
525
|
+
anchor.setAttribute('data-dsh-part', ANCHOR_PART)
|
|
526
|
+
anchor.className = 'dsh-url-router-link'
|
|
527
|
+
anchor.addEventListener('click', event => {
|
|
528
|
+
if (event === null || event.defaultPrevented === true) return
|
|
529
|
+
const plain = (event.button === undefined || event.button === 0)
|
|
530
|
+
&& event.metaKey !== true && event.ctrlKey !== true
|
|
531
|
+
&& event.shiftKey !== true && event.altKey !== true
|
|
532
|
+
if (!plain) return
|
|
533
|
+
if (typeof event.preventDefault === 'function') event.preventDefault()
|
|
534
|
+
if (typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
535
|
+
try {
|
|
536
|
+
open()
|
|
537
|
+
} catch {
|
|
538
|
+
// Navigation belongs to the view owner: if it refuses, the link has
|
|
539
|
+
// still done nothing harmful to the shell.
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
return anchor
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Give one row its anchor, if it needs one.
|
|
547
|
+
* @param {object} document - the document the row lives in.
|
|
548
|
+
* @param {object} row - candidate row element.
|
|
549
|
+
* @returns {boolean} whether the row now carries an anchor.
|
|
550
|
+
*/
|
|
551
|
+
function mountAnchor(document, row, href, open) {
|
|
552
|
+
if (typeof row.querySelector !== 'function' || typeof row.appendChild !== 'function') return false
|
|
553
|
+
if (row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) return true
|
|
554
|
+
// The anchor is absolutely positioned, so the row must be a positioning
|
|
555
|
+
// context; only take that over when the row does not already establish one,
|
|
556
|
+
// and remember which case it was so disposal only undoes our own change.
|
|
557
|
+
const style = typeof window.getComputedStyle === 'function' ? window.getComputedStyle(row) : undefined
|
|
558
|
+
const owns = style === undefined || style === null || style.position === 'static'
|
|
559
|
+
if (owns && row.style !== undefined) row.style.position = 'relative'
|
|
560
|
+
if (typeof row.setAttribute === 'function') row.setAttribute(HOST_ATTRIBUTE, owns ? 'own' : 'shared')
|
|
561
|
+
const anchor = rowAnchor(document, href, open)
|
|
562
|
+
// Prepended so the row's own controls — lifted by the stylesheet — stay in
|
|
563
|
+
// front of the overlay; appended, the overlay would swallow them.
|
|
564
|
+
if (typeof row.insertBefore === 'function') row.insertBefore(anchor, row.firstChild ?? null)
|
|
565
|
+
else row.appendChild(anchor)
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Slot the sidebar renders its main-panel rows from. */
|
|
570
|
+
const PANEL_LIST_SLOT = 'sidebar.panellist'
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Read the registered main panels in the order the sidebar renders them.
|
|
574
|
+
*
|
|
575
|
+
* The registry is the only source of a panel's identity: the row itself carries
|
|
576
|
+
* its label and nothing else. A label may be a function (resolved per locale by
|
|
577
|
+
* the shell), so it is only used for matching when it is a plain string; rows are
|
|
578
|
+
* aligned by `order` otherwise.
|
|
579
|
+
* @param {object} slots - the client slots service, when the composition has one.
|
|
580
|
+
* @returns {Array<{id: string, order: number, label: string|undefined}>} registry rows.
|
|
581
|
+
*/
|
|
582
|
+
function readPanels(slots) {
|
|
583
|
+
if (typeof slots?.entries !== 'function') return []
|
|
584
|
+
let stored
|
|
585
|
+
try {
|
|
586
|
+
stored = slots.entries(PANEL_LIST_SLOT)
|
|
587
|
+
} catch {
|
|
588
|
+
return []
|
|
589
|
+
}
|
|
590
|
+
if (!Array.isArray(stored)) return []
|
|
591
|
+
return stored
|
|
592
|
+
.map((entry, index) => {
|
|
593
|
+
const options = entry?.options ?? {}
|
|
594
|
+
const id = typeof options.id === 'string' ? options.id : undefined
|
|
595
|
+
const order = typeof options.order === 'number' ? options.order : index
|
|
596
|
+
const label = typeof options.label === 'string' ? options.label : undefined
|
|
597
|
+
return { id, order, label, index }
|
|
598
|
+
})
|
|
599
|
+
.filter(entry => entry.id !== undefined && entry.id !== '')
|
|
600
|
+
.sort((left, right) => left.order - right.order || left.index - right.index)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The main-panel rows the official sidebar renders.
|
|
605
|
+
*
|
|
606
|
+
* Located structurally (`data-slot`) rather than by a localized accessible name,
|
|
607
|
+
* so the same hook works in every language.
|
|
608
|
+
* @param {object} document - the document to scan.
|
|
609
|
+
* @returns {Array} the panel row elements, in render order.
|
|
610
|
+
*/
|
|
611
|
+
function panelRows(document) {
|
|
612
|
+
const rows = []
|
|
613
|
+
try {
|
|
614
|
+
for (const button of document.querySelectorAll('button')) {
|
|
615
|
+
if (typeof button.querySelector === 'function' && button.querySelector(`[data-slot="${PANEL_LIST_SLOT}"]`) !== null) rows.push(button)
|
|
616
|
+
}
|
|
617
|
+
} catch {
|
|
618
|
+
return []
|
|
619
|
+
}
|
|
620
|
+
return rows
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Give one conversation row its link to the conversation route.
|
|
625
|
+
* @param {object} document - the document the row lives in.
|
|
626
|
+
* @param {object} row - candidate row element.
|
|
627
|
+
* @param {Function} openSession - navigation for a plain left click.
|
|
628
|
+
* @returns {boolean} whether the row now carries an anchor.
|
|
629
|
+
*/
|
|
630
|
+
function enhanceRow(document, row, openSession) {
|
|
631
|
+
const id = sessionIdOfRow(row)
|
|
632
|
+
if (id === undefined) return false
|
|
633
|
+
return mountAnchor(document, row, routeHash('session', id), () => openSession(id))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Give one main-panel row its link to the panel route.
|
|
638
|
+
*
|
|
639
|
+
* A panel row carries no identity in the DOM, so the id has to come from the
|
|
640
|
+
* registry the sidebar renders from; rows are matched by accessible name when
|
|
641
|
+
* the registered label is a plain string, and by position otherwise. A row that
|
|
642
|
+
* cannot be matched is left exactly as it was.
|
|
643
|
+
* @param {object} document - the document the row lives in.
|
|
644
|
+
* @param {object} row - candidate row element.
|
|
645
|
+
* @param {Array} panels - the registry rows, in render order.
|
|
646
|
+
* @param {Function} selectPanel - panel selection for a plain left click.
|
|
647
|
+
* @returns {boolean} whether the row now carries an anchor.
|
|
648
|
+
*/
|
|
649
|
+
function enhancePanelRow(document, row, panels, index, useIndex, selectPanel) {
|
|
650
|
+
const name = typeof row.getAttribute === 'function' ? row.getAttribute('aria-label') : undefined
|
|
651
|
+
let target
|
|
652
|
+
if (typeof name === 'string' && name !== '') target = panels.find(entry => entry.label === name)
|
|
653
|
+
if (target === undefined && useIndex) target = panels[index]
|
|
654
|
+
if (target === undefined) return false
|
|
655
|
+
return mountAnchor(document, row, routeHash('panel', target.id), () => selectPanel(target.id))
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Turn every conversation row into a link, and keep doing so as the list
|
|
660
|
+
* virtualizes.
|
|
661
|
+
* @param {object} document - the document to enhance (may be absent).
|
|
662
|
+
* @returns {() => void} the disposer removing every anchor, the stylesheet and the observer.
|
|
663
|
+
*/
|
|
664
|
+
function startRowLinks(document, openSession, slots, selectPanel) {
|
|
665
|
+
if (document === undefined || document === null) return () => {}
|
|
666
|
+
if (typeof document.createElement !== 'function' || typeof document.querySelectorAll !== 'function') return () => {}
|
|
667
|
+
const scan = () => {
|
|
668
|
+
let linked = 0
|
|
669
|
+
try {
|
|
670
|
+
for (const row of document.querySelectorAll(ROW_SELECTOR)) if (enhanceRow(document, row, openSession)) linked += 1
|
|
671
|
+
} catch {
|
|
672
|
+
// A DOM that cannot be scanned is left untouched: the address-bar sync
|
|
673
|
+
// is the plugin's contract, the row links are an addition to it.
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
for (const row of document.querySelectorAll('li[data-plugin-item]')) {
|
|
677
|
+
const id = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-item') : null
|
|
678
|
+
if (typeof id !== 'string' || id === '') continue
|
|
679
|
+
const open = () => {
|
|
680
|
+
selectPanel(PLUGINS_PANEL_ID)
|
|
681
|
+
const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
|
|
682
|
+
if (typeof button?.click === 'function') button.click()
|
|
683
|
+
}
|
|
684
|
+
if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, id), open)) linked += 1
|
|
685
|
+
}
|
|
686
|
+
} catch {
|
|
687
|
+
// Same policy as every other row pass.
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
for (const row of document.querySelectorAll(`li[data-plugin-package]`)) {
|
|
691
|
+
const name = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-package') : null
|
|
692
|
+
if (typeof name !== 'string' || name === '') continue
|
|
693
|
+
const open = () => {
|
|
694
|
+
// Select the owning panel first, then let its own control open the page:
|
|
695
|
+
// the panel stays the only thing that knows how a page opens.
|
|
696
|
+
selectPanel(PLUGINS_PANEL_ID)
|
|
697
|
+
const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
|
|
698
|
+
if (typeof button?.click === 'function') button.click()
|
|
699
|
+
}
|
|
700
|
+
if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, name), open)) linked += 1
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
// Same policy: a list row that cannot be read stays as it was.
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const panels = readPanels(slots)
|
|
707
|
+
const rows = panelRows(document)
|
|
708
|
+
// Index alignment is only trustworthy when every registered panel has a
|
|
709
|
+
// row; otherwise a row is linked only when its label matches exactly.
|
|
710
|
+
const useIndex = panels.length > 0 && panels.length === rows.length
|
|
711
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
712
|
+
if (enhancePanelRow(document, rows[index], panels, index, useIndex, selectPanel)) linked += 1
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
// Same policy for the panel list: an unmatched row stays as it was.
|
|
716
|
+
}
|
|
717
|
+
return linked
|
|
718
|
+
}
|
|
719
|
+
let style
|
|
720
|
+
try {
|
|
721
|
+
style = document.createElement('style')
|
|
722
|
+
style.id = STYLE_ID
|
|
723
|
+
style.textContent = STYLE_TEXT
|
|
724
|
+
const parent = document.head ?? document.body
|
|
725
|
+
if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
|
|
726
|
+
} catch {
|
|
727
|
+
style = undefined
|
|
728
|
+
}
|
|
729
|
+
let observer
|
|
730
|
+
try {
|
|
731
|
+
const root = document.body ?? document.documentElement
|
|
732
|
+
if (root !== undefined && root !== null && typeof window.MutationObserver === 'function') {
|
|
733
|
+
observer = new window.MutationObserver(scan)
|
|
734
|
+
observer.observe(root, { childList: true, subtree: true })
|
|
735
|
+
}
|
|
736
|
+
} catch {
|
|
737
|
+
observer = undefined
|
|
738
|
+
}
|
|
739
|
+
const linked = scan()
|
|
740
|
+
if (linked === undefined) return () => {}
|
|
741
|
+
if (typeof console.info === 'function') console.info(`${TAG} row links: every conversation row is a real link (right-click menu, middle/Cmd-click new tab)`)
|
|
742
|
+
return () => {
|
|
743
|
+
try {
|
|
744
|
+
observer?.disconnect()
|
|
745
|
+
style?.remove()
|
|
746
|
+
for (const row of [...document.querySelectorAll(ROW_SELECTOR), ...panelRows(document)]) {
|
|
747
|
+
row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`)?.remove()
|
|
748
|
+
const host = typeof row.getAttribute === 'function' ? row.getAttribute(HOST_ATTRIBUTE) : null
|
|
749
|
+
if (host !== null && typeof row.removeAttribute === 'function') {
|
|
750
|
+
row.removeAttribute(HOST_ATTRIBUTE)
|
|
751
|
+
// Only undo the positioning context this plugin established itself.
|
|
752
|
+
if (host === 'own' && row.style !== undefined) row.style.position = ''
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
} catch {
|
|
756
|
+
// Disposal is best effort; nothing here may break the shell.
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// ---------------------------------------------------------------------
|
|
761
|
+
// browser wiring: probe the official faces, then drive the machine.
|
|
762
|
+
// ---------------------------------------------------------------------
|
|
763
|
+
/** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
|
|
764
|
+
const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots']
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Probe the faces the router needs.
|
|
768
|
+
*
|
|
769
|
+
* A changed surface returns null so the plugin leaves the official behavior
|
|
770
|
+
* untouched. The layout face is optional: without it the plugin still routes
|
|
771
|
+
* conversations and simply never claims a panel.
|
|
772
|
+
* @param {object} scope - client context.
|
|
773
|
+
* @returns {{faces: object}|null} the probed faces, or null when the session faces are unusable.
|
|
774
|
+
*/
|
|
775
|
+
function probeFaces(scope) {
|
|
776
|
+
const list = scope.sessions?.list
|
|
777
|
+
const registry = scope.workspaces?.list
|
|
778
|
+
const navigation = scope.uiWorkspace
|
|
779
|
+
if (typeof list !== 'object' || list === null) return null
|
|
780
|
+
if (typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return null
|
|
781
|
+
if (typeof registry !== 'object' || registry === null) return null
|
|
782
|
+
if (typeof registry.getSnapshot !== 'function') return null
|
|
783
|
+
if (typeof navigation !== 'object' || navigation === null) return null
|
|
784
|
+
const openSession = navigation.openSession
|
|
785
|
+
if (typeof openSession !== 'function') return null
|
|
786
|
+
// `slots` is declared in `inject`, but a composition may still omit the package:
|
|
787
|
+
// reading it defensively keeps the panel links optional rather than fatal.
|
|
788
|
+
let slots
|
|
789
|
+
try {
|
|
790
|
+
slots = scope.slots
|
|
791
|
+
} catch {
|
|
792
|
+
slots = undefined
|
|
793
|
+
}
|
|
794
|
+
const layout = typeof scope.get === 'function' ? scope.get('layout') : undefined
|
|
795
|
+
const panelInfo = layout?.panelInfo
|
|
796
|
+
const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
|
|
797
|
+
const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
|
|
798
|
+
const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
|
|
799
|
+
return {
|
|
800
|
+
faces: {
|
|
801
|
+
catalog: list,
|
|
802
|
+
archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
|
|
803
|
+
openSession: id => openSession.call(navigation, id),
|
|
804
|
+
selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
|
|
805
|
+
beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
|
|
806
|
+
panels,
|
|
807
|
+
slots,
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Build the live browser host over the probed faces.
|
|
814
|
+
* @param {object} faces - the probed faces.
|
|
815
|
+
* @returns {object} the host the state machine drives.
|
|
816
|
+
*/
|
|
817
|
+
function browserHost(faces) {
|
|
818
|
+
/**
|
|
819
|
+
* Call a host face defensively: a refused navigation or a service that
|
|
820
|
+
* changed its mind must never take the page down with it.
|
|
821
|
+
*/
|
|
822
|
+
const attempt = (fn, fallback) => {
|
|
823
|
+
try {
|
|
824
|
+
return fn()
|
|
825
|
+
} catch {
|
|
826
|
+
return fallback
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const archived = () => {
|
|
830
|
+
const ids = attempt(() => faces.archivedSessionIds(), [])
|
|
831
|
+
return Array.isArray(ids) ? ids : []
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
|
|
835
|
+
activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
|
|
836
|
+
// The panel that owns inner pages advertises them with a data attribute; the
|
|
837
|
+
// router only reads it, never the panel's internals.
|
|
838
|
+
// The inner page a panel is showing, in that panel's own vocabulary. Each panel
|
|
839
|
+
// publishes it with a data attribute; reading it is the whole reflect direction.
|
|
840
|
+
panelPage: (panelId, scope) => {
|
|
841
|
+
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
842
|
+
// The form and a task are mutually exclusive, and the form wins when both
|
|
843
|
+
// would be reported (it is the page the user is looking at).
|
|
844
|
+
if (window.document?.querySelector?.(`[${TASK_BOARD_NEW_ATTRIBUTE}]`) != null) return ['new']
|
|
845
|
+
const value = attributeOf(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`, TASK_BOARD_OPEN_ATTRIBUTE)
|
|
846
|
+
return value === undefined ? undefined : ['task', value]
|
|
847
|
+
}
|
|
848
|
+
if (panelId !== PLUGINS_PANEL_ID) return undefined
|
|
849
|
+
const row = splitRowHook(attributeOf('[data-plugin-row-detail]', 'data-plugin-row-detail'))
|
|
850
|
+
if (row !== undefined) {
|
|
851
|
+
// The page names its package when it can; otherwise the package is the deepest
|
|
852
|
+
// segment the address bar already carried.
|
|
853
|
+
const pkg = row.packageName ?? scope
|
|
854
|
+
return pkg === undefined ? [row.rowId] : innerPath([pkg, row.rowId])
|
|
855
|
+
}
|
|
856
|
+
// The built-in plugins (the official group) have their own page hook.
|
|
857
|
+
const item = attributeOf('[data-plugin-item-detail]', 'data-plugin-item-detail')
|
|
858
|
+
if (item !== undefined) return [item]
|
|
859
|
+
const detail = attributeOf('[data-plugin-detail]', 'data-plugin-detail')
|
|
860
|
+
return detail === undefined ? undefined : [detail]
|
|
861
|
+
},
|
|
862
|
+
openPanelPage: (panelId, inner) => {
|
|
863
|
+
const asked = innerPath(inner)
|
|
864
|
+
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
865
|
+
// The task board owns how its overlays open: we hand over the request on its
|
|
866
|
+
// own contract (window events) and nothing else crosses the seam.
|
|
867
|
+
if (asked[0] === 'new' && asked.length === 1) {
|
|
868
|
+
attempt(() => window.dispatchEvent(new window.CustomEvent(TASK_BOARD_NEW_EVENT)))
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
if (asked[0] !== 'task' || asked.length < 2) return
|
|
872
|
+
attempt(() =>
|
|
873
|
+
window.dispatchEvent(
|
|
874
|
+
new window.CustomEvent(TASK_BOARD_OPEN_EVENT, { detail: { taskId: asked.slice(1).join('/') } }),
|
|
875
|
+
),
|
|
876
|
+
)
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
if (panelId !== PLUGINS_PANEL_ID) return
|
|
880
|
+
const segments = asked
|
|
881
|
+
if (segments.length === 0) return
|
|
882
|
+
// The component page lives inside its package page, so that page is opened
|
|
883
|
+
// first and the row is clicked once it exists.
|
|
884
|
+
if (segments.length === 1 || window.document?.querySelector?.('[data-plugin-detail]') === null) {
|
|
885
|
+
// A segment names either an installed package or a built-in plugin.
|
|
886
|
+
const pkg =
|
|
887
|
+
window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
|
|
888
|
+
window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
|
|
889
|
+
const button = pkg?.querySelector?.('button')
|
|
890
|
+
if (typeof button?.click === 'function') button.click()
|
|
891
|
+
if (segments.length === 1) return
|
|
892
|
+
}
|
|
893
|
+
const rowId = segments[segments.length - 1]
|
|
894
|
+
const rows = window.document?.querySelectorAll?.('li[data-plugin-row]') ?? []
|
|
895
|
+
for (const row of rows) {
|
|
896
|
+
const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
|
|
897
|
+
if (parsed === undefined || parsed.rowId !== rowId) continue
|
|
898
|
+
const button = row.querySelector?.('button')
|
|
899
|
+
if (typeof button?.click === 'function') button.click()
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
isArchived: id => archived().includes(id),
|
|
904
|
+
isOpenable: id =>
|
|
905
|
+
attempt(() => {
|
|
906
|
+
const snapshot = faces.catalog.getSnapshot()
|
|
907
|
+
if (snapshot.phase !== 'ready') return false
|
|
908
|
+
if (archived().includes(id)) return false
|
|
909
|
+
return snapshot.ids.includes(id) || snapshot.byId[id] !== undefined
|
|
910
|
+
}, false),
|
|
911
|
+
// A panel id nobody registered is refused by the layout by THROWING
|
|
912
|
+
// (`selectPanel: main panel "x" is not registered`), so the claim is
|
|
913
|
+
// swallowed here and reported as "the view did not move" at expiry.
|
|
914
|
+
openSession: id => {
|
|
915
|
+
attempt(() => faces.openSession(id))
|
|
916
|
+
},
|
|
917
|
+
beginNavigation: () => {
|
|
918
|
+
if (faces.beginNavigation === undefined) return
|
|
919
|
+
attempt(() => faces.beginNavigation())
|
|
920
|
+
},
|
|
921
|
+
selectPanel: id => {
|
|
922
|
+
if (faces.selectPanel === undefined) return
|
|
923
|
+
attempt(() => faces.selectPanel(id))
|
|
924
|
+
},
|
|
925
|
+
readLocation: () =>
|
|
926
|
+
attempt(() => ({
|
|
927
|
+
pathname: window.location.pathname,
|
|
928
|
+
search: window.location.search,
|
|
929
|
+
hash: window.location.hash,
|
|
930
|
+
}), { pathname: '', search: '', hash: '' }),
|
|
931
|
+
replaceUrl: url => {
|
|
932
|
+
attempt(() => window.history.replaceState(null, '', url))
|
|
933
|
+
},
|
|
934
|
+
onPanelChange: handler => (faces.panels === undefined ? () => {} : attempt(() => faces.panels.subscribe(handler), () => {})),
|
|
935
|
+
onCatalogChange: handler => attempt(() => faces.catalog.subscribe(handler), () => {}),
|
|
936
|
+
// A panel's inner page is reported by attributes the panel itself publishes, so
|
|
937
|
+
// DOM changes are a notification source of their own (list <-> detail).
|
|
938
|
+
onDomChange: handler =>
|
|
939
|
+
attempt(() => {
|
|
940
|
+
const root = window.document?.body ?? window.document?.documentElement
|
|
941
|
+
if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
|
|
942
|
+
const observer = new window.MutationObserver(handler)
|
|
943
|
+
// Every published page hook must be watched: a filter that names only some of
|
|
944
|
+
// them silently starves the retry loop of notifications (real bug: the task
|
|
945
|
+
// board's drive looked flaky because its attributes were not observed).
|
|
946
|
+
observer.observe(root, {
|
|
947
|
+
childList: true,
|
|
948
|
+
subtree: true,
|
|
949
|
+
attributes: true,
|
|
950
|
+
attributeFilter: [
|
|
951
|
+
'data-plugin-detail',
|
|
952
|
+
'data-plugin-item-detail',
|
|
953
|
+
'data-plugin-row-detail',
|
|
954
|
+
TASK_BOARD_OPEN_ATTRIBUTE,
|
|
955
|
+
TASK_BOARD_NEW_ATTRIBUTE,
|
|
956
|
+
],
|
|
957
|
+
})
|
|
958
|
+
return () => observer.disconnect()
|
|
959
|
+
}, () => {}),
|
|
960
|
+
onHashChange: handler =>
|
|
961
|
+
attempt(() => {
|
|
962
|
+
window.addEventListener('hashchange', handler)
|
|
963
|
+
window.addEventListener('popstate', handler)
|
|
964
|
+
return () => {
|
|
965
|
+
window.removeEventListener('hashchange', handler)
|
|
966
|
+
window.removeEventListener('popstate', handler)
|
|
967
|
+
}
|
|
968
|
+
}, () => {}),
|
|
969
|
+
now: () => attempt(() => Date.now(), 0),
|
|
970
|
+
setTimer: (handler, delayMs) =>
|
|
971
|
+
attempt(() => {
|
|
972
|
+
const timer = window.setTimeout(handler, delayMs)
|
|
973
|
+
return () => window.clearTimeout(timer)
|
|
974
|
+
}, () => {}),
|
|
975
|
+
warn: message => console.warn(message),
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Register the URL routing on the running GUI.
|
|
981
|
+
* @param {object} ctx - client root context.
|
|
982
|
+
*/
|
|
983
|
+
function apply(ctx) {
|
|
984
|
+
// Fragment routing: the address bar names the view, and a link opens it.
|
|
985
|
+
ctx.effect(() => {
|
|
986
|
+
if (typeof window === 'undefined') return () => {}
|
|
987
|
+
const probed = probeFaces(ctx)
|
|
988
|
+
if (probed === null) {
|
|
989
|
+
console.warn(`${TAG} disabled: the sessions/workspaces/uiWorkspace service shape changed`)
|
|
990
|
+
return () => {}
|
|
991
|
+
}
|
|
992
|
+
if (probed.faces.panels === undefined) {
|
|
993
|
+
console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
|
|
994
|
+
}
|
|
995
|
+
return startUrlRouting(browserHost(probed.faces))
|
|
996
|
+
}, 'url-router: fragment routing')
|
|
997
|
+
// Row links: every conversation row is exposed as a real link to its route, so
|
|
998
|
+
// the browser's own link menu (open in new tab / copy link) works on it.
|
|
999
|
+
ctx.effect(() => {
|
|
1000
|
+
if (typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
1001
|
+
const probed = probeFaces(ctx)
|
|
1002
|
+
if (probed === null) return () => {}
|
|
1003
|
+
const openSession = id => {
|
|
1004
|
+
try {
|
|
1005
|
+
probed.faces.openSession(id)
|
|
1006
|
+
} catch {
|
|
1007
|
+
// A refused navigation must not escape into the anchor's click handler.
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const selectPanel = id => {
|
|
1011
|
+
try {
|
|
1012
|
+
probed.faces.selectPanel(id)
|
|
1013
|
+
} catch {
|
|
1014
|
+
// The layout throws for an id nobody registered; a link must not.
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const slots = probed.faces.slots
|
|
1018
|
+
return startRowLinks(window.document, openSession, slots, selectPanel)
|
|
1019
|
+
}, 'url-router: row links')
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
exports.apply = apply
|
|
1023
|
+
exports.inject = inject
|
|
1024
|
+
return module.exports
|
|
1025
|
+
},
|
|
1026
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-url-router · host half.
|
|
3
|
+
*
|
|
4
|
+
* No host behavior: this plugin has no route, no tool, and no service. It exists
|
|
5
|
+
* so the package is a well-formed bundle row (the row needs a `main` entry); the
|
|
6
|
+
* whole feature lives in the browser half, `./client`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Apply the host half.
|
|
11
|
+
* @param {object} _ctx - host plugin context (unused).
|
|
12
|
+
*/
|
|
13
|
+
export function apply(_ctx) {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-url-router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DSH Web GUI plugin: one URL for every surface — the address bar names what is on screen (a conversation or a main panel) and a link opens it. Single owner of the URL fragment; browser half only, no dependencies, no telemetry.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/DDDMUC/dsh-url-router.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/index.js",
|
|
13
|
+
"./client": "./lib/client.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test \"test/*.test.js\""
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"engines": {
|
|
21
|
+
"dsh": ">=0.2.0-rc.2"
|
|
22
|
+
},
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web",
|
|
28
|
+
"inject": [
|
|
29
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
30
|
+
"@deepseek-ai/dsh-api-workspace-controller",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-workspace",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"compatibility": {
|
|
36
|
+
"dshReleases": {
|
|
37
|
+
"0.2.0-rc.2": "compatible"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"lib",
|
|
43
|
+
"cordis.patch.yml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"AGENTS.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
],
|
|
48
|
+
"keywords": [
|
|
49
|
+
"deepseek-harness",
|
|
50
|
+
"dsh",
|
|
51
|
+
"plugin",
|
|
52
|
+
"url",
|
|
53
|
+
"router",
|
|
54
|
+
"deep-link"
|
|
55
|
+
],
|
|
56
|
+
"license": "MIT"
|
|
57
|
+
}
|