dsh-synced-tabs 0.0.0-stage → 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/COMPATIBILITY.md +303 -0
- package/LICENSE +21 -0
- package/README.en.md +109 -0
- package/README.md +90 -2
- package/client.js +1181 -0
- package/cordis.patch.yml +10 -0
- package/docs/DESIGN.md +665 -0
- package/index.js +15 -0
- package/lib/client.d.ts +211 -0
- package/lib/host.js +295 -0
- package/package.json +70 -3
package/client.js
ADDED
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
// dsh-synced-tabs 客户端半边:标签栏 + 左栏会话行的 workspace 左沿色条 + 「共享集合」同步客户端。
|
|
2
|
+
// 手写纯 JS,无构建、无 `@deepseek-ai/dsh-client-*` 模块请求,唯一模块请求是 `react`(平台种子模块表),
|
|
3
|
+
// 故 `dsh.client.external` 为空。**DSH 一个包只能有一个客户端 bundle**(`exports["./client"]` 是客户端模块
|
|
4
|
+
// 系统硬编码读取的唯一入口,见 dsh-client-modules `clientExportOf`),所以两块共用本文件、各自注册槽位。
|
|
5
|
+
// 状态模型:真相在宿主(`lib/host.js` 的 `/api/dsh-tabs/state`),本设备只持缓存;渲染读乐观视图,本地操作
|
|
6
|
+
// 先落界面再按队列补发;`seq` 只判断答复是否比缓存新。宿主契约见 `lib/client.d.ts`,硬约束见 `README.md` §4。
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
// 注册 id 必须等于**包名**:dsh-client-modules 的 boot graph 行键就是包名,加载器只认
|
|
9
|
+
// `registration.id === row.id`;不一致时宿主报 "loaded without registering" 且整个 web bundle
|
|
10
|
+
// 不激活。`dsh-tabs` 是改名前的包名——改名漏了这一处(2026-11-10)。
|
|
11
|
+
id: 'dsh-synced-tabs',
|
|
12
|
+
factory(require) {
|
|
13
|
+
const React = require('react');
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
|
|
16
|
+
const SLOT = 'shell.overlay'; // 唯一挂载点:官方浮层槽位。
|
|
17
|
+
const CELL_ID = 'dsh-tabs'; // 槽位里的格子 id——用自己的 id 是「加一条」,复用别人的 id 才是「替换它」。
|
|
18
|
+
const STATE_API = '/api/dsh-tabs/state'; // 宿主共享集合端点:Host 半边注册的精确 Fetch 路由(见 lib/host.js)。
|
|
19
|
+
const CACHE_KEY = 'dsh-tabs:state'; // 本设备缓存的键:**跨窗口共享**,首屏与宿主暂时不可达时用它。
|
|
20
|
+
const LEGACY_PREFIX = 'dsh-tabs:tabs:'; // 旧版「按窗口分键」的存储前缀;只在首次迁移时读一次,读完删掉。
|
|
21
|
+
const TABS_VERSION = 1; // 存储/协议载荷版本;不认识版本的载荷一律当空集合(不猜)。
|
|
22
|
+
const POLL_MS = 3000; // 轮询间隔:标签变更是低频事件,3s 足够让另一台设备「几乎立刻」跟上。
|
|
23
|
+
const RETRY_MS = 200; // 布局避让:拿不到锚点时的重试节奏与上限;`FALLBACK_H` 是测量出真实高度前的占位。
|
|
24
|
+
const RETRY_MAX = 5;
|
|
25
|
+
const FALLBACK_H = '38px';
|
|
26
|
+
// 窄屏断点(P2-2):横条降级为「当前会话 + 抽屉按钮」,全部标签进抽屉。
|
|
27
|
+
// CSS 的 `@media` 与 JS 的 `matchMedia` 共用这一个数——两处写死不同值会得到「横条变了、抽屉不开」这类半个功能。
|
|
28
|
+
//
|
|
29
|
+
// 767 而不是随手一个数:宿主自 0.2.2 起在 `innerWidth < 768` 时把侧边栏降级成**离屏抽屉**,
|
|
30
|
+
// 唯一的入口是左上角 (12,11) 那颗 28px 浮动「打开侧边栏」按钮(源码 `SIDEBAR_AUTO_COLLAPSE` / `drawer`)。
|
|
31
|
+
// 顶部横条会正压住它,实测点 (26,25) 命中的是我们的 `.dshtabs-dot`,侧边栏因此永远打不开。
|
|
32
|
+
// 所以窄屏把横条整体挪到底部、顶部整条让给宿主;767 与宿主的 768 是同一个分界点。
|
|
33
|
+
const NARROW_PX = 767;
|
|
34
|
+
const NARROW_QUERY = '(max-width:' + NARROW_PX + 'px)';
|
|
35
|
+
|
|
36
|
+
// ── 纯函数:离线测试的全部着力点 ──
|
|
37
|
+
function warn(message) {
|
|
38
|
+
console.warn('[dsh-synced-tabs] ' + message);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function once() { // 同一个原因只告警一次——降级路径不该刷屏。
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
return (key, message) => {
|
|
44
|
+
if (seen.has(key)) return;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
warn(message);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 状态点类型,优先级 `等待交互 > 运行中 > 完成未读 > 空闲`。调用方只在**真的拿到状态**时才调它:
|
|
51
|
+
// `status` 为空返回 `'idle'` 只是防御,那条路径上根本不画点(P0-6)。
|
|
52
|
+
function statusKind(status) {
|
|
53
|
+
if (!status) return 'idle';
|
|
54
|
+
if (status.pendingInteraction) return 'pending';
|
|
55
|
+
if (status.running) return 'running';
|
|
56
|
+
if (status.completionUnread) return 'unread';
|
|
57
|
+
return 'idle';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const STATUS_TEXT = { running: '运行中', pending: '等待你的回答', unread: '已完成未读', idle: '空闲' }; // 状态点的悬停说明:点只有 7px,语义必须能问出来(四个 kind 全覆盖)。
|
|
61
|
+
|
|
62
|
+
// 抽屉按钮上的**聚合**状态点:一个标签的状态选出来了,多个标签必须收成一个,否则按钮上要画一串点。
|
|
63
|
+
// 优先级与单标签的 `statusKind` **故意不同**:单标签回答「这条会话在干什么」(运行中就画运行中,
|
|
64
|
+
// 哪怕它同时有未读完成);聚合回答「哪一条在等我」,所以未读完成排在运行中前面——运行中的事还在
|
|
65
|
+
// 自己跑,未读完成的才需要你去看。空闲不参与聚合:全是空闲就一个点都不画。
|
|
66
|
+
const ATTENTION_ORDER = ['pending', 'unread', 'running'];
|
|
67
|
+
|
|
68
|
+
function attentionKind(statuses, ids) {
|
|
69
|
+
if (!statuses || typeof statuses.get !== 'function') return undefined;
|
|
70
|
+
let best;
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
const status = statuses.get(id);
|
|
73
|
+
if (!status) continue; // 没拿到状态就不猜(P0-6),也不参与聚合
|
|
74
|
+
const kind = statusKind(status);
|
|
75
|
+
const rank = ATTENTION_ORDER.indexOf(kind);
|
|
76
|
+
if (rank < 0) continue;
|
|
77
|
+
if (best === undefined || rank < ATTENTION_ORDER.indexOf(best)) best = kind;
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isId(id) { // 一条合法的会话 id:非空字符串、长度有上限(宿主对同一字段做同一套校验)。
|
|
83
|
+
return typeof id === 'string' && id !== '' && id.length <= 200;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sanitizeIds(list) { // 去重 + 丢掉非法项。集合的每一项都必须是一个会话 id。
|
|
87
|
+
const ids = [];
|
|
88
|
+
for (const id of list) if (isId(id) && ids.indexOf(id) < 0) ids.push(id);
|
|
89
|
+
return ids;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function appendTab(ids, id) { // 追加语义:已存在就原样返回(不重排、不产生第二个标签)。
|
|
93
|
+
return !id || ids.indexOf(id) >= 0 ? ids : ids.concat(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function removeTab(ids, id) {
|
|
97
|
+
const at = ids.indexOf(id);
|
|
98
|
+
return at < 0 ? ids : ids.slice(0, at).concat(ids.slice(at + 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 把一批操作应用到集合上。**宿主侧有同一个函数**(lib/host.js 的 applyOps):客户端做乐观更新、宿主做
|
|
102
|
+
// 权威更新,两侧对 op 的幂等语义必须一致(改一处必须改两处)。`workspaces` 是会话 → workspaceId 的
|
|
103
|
+
// **兜底**归属表(真相在 workspace 列表,见 createSync):`open` 带合法 workspaceId 时记下、不带时保留,
|
|
104
|
+
// `close` 删掉;返回新对象,不改入参。
|
|
105
|
+
function applyOps(ids, ops, workspaces) {
|
|
106
|
+
let next = ids;
|
|
107
|
+
const nextWorkspaces = Object.assign({}, sanitizeWorkspaces(workspaces));
|
|
108
|
+
for (const op of ops) {
|
|
109
|
+
if (!op || typeof op !== 'object' || !isId(op.id)) continue;
|
|
110
|
+
if (op.op === 'open') {
|
|
111
|
+
const appended = appendTab(next, op.id);
|
|
112
|
+
if (appended !== next) next = appended;
|
|
113
|
+
if (isId(op.workspaceId) && workspaces[op.id] !== op.workspaceId) nextWorkspaces[op.id] = op.workspaceId;
|
|
114
|
+
} else if (op.op === 'close') {
|
|
115
|
+
next = removeTab(next, op.id);
|
|
116
|
+
delete nextWorkspaces[op.id];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { ids: next, workspaces: nextWorkspaces, changed: next !== ids || !sameWorkspaces(workspaces, nextWorkspaces) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 收敛兜底归属表:只留 `{ 合法会话 id: 合法 workspaceId }`;缺字段(老缓存 / 老宿主)与写坏的载荷
|
|
123
|
+
// 一律当空表——兜底信息丢了不是错误,不告警。
|
|
124
|
+
function sanitizeWorkspaces(raw) {
|
|
125
|
+
const table = {};
|
|
126
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return table;
|
|
127
|
+
for (const id of Object.keys(raw)) if (isId(id) && isId(raw[id])) table[id] = raw[id];
|
|
128
|
+
return table;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sameWorkspaces(left, right) { // 两张归属表是否等价(键序无关)。
|
|
132
|
+
const keys = Object.keys(left);
|
|
133
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
134
|
+
for (const key of keys) if (left[key] !== right[key]) return false;
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sameList(left, right) {
|
|
139
|
+
if (left.length !== right.length) return false;
|
|
140
|
+
for (let i = 0; i < left.length; i += 1) if (left[i] !== right[i]) return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function activeSession(byId) { // 当前显示在主区的会话——与 DSH 自己的 `isMain()` 同口径:`retainedBy.mainView > 0`。
|
|
145
|
+
if (!byId) return undefined;
|
|
146
|
+
for (const id of Object.keys(byId)) {
|
|
147
|
+
const row = byId[id];
|
|
148
|
+
if (row && row.retainedBy && (row.retainedBy.mainView || 0) > 0) return id;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function closeTarget(ids, at) { // 关闭落点:右邻 → 左邻 → 无(`undefined`)。
|
|
154
|
+
if (at < 0 || at >= ids.length) return undefined;
|
|
155
|
+
return ids[at + 1] !== undefined ? ids[at + 1] : ids[at - 1];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function nextIndex(key, at, count) { // 键盘落点:`←/→` 环绕、`Home/End` 到两端;不认识的键返回 -1(调用方不拦截)。
|
|
159
|
+
if (count <= 0) return -1;
|
|
160
|
+
if (key === 'ArrowRight') return (at + 1 + count) % count;
|
|
161
|
+
if (key === 'ArrowLeft') return (at - 1 + count) % count;
|
|
162
|
+
if (key === 'Home') return 0;
|
|
163
|
+
if (key === 'End') return count - 1;
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// workspace 分组色:DSH 官方主题令牌。不按 id 哈希——6 个 workspace 必然撞色,这里要两两不同,且序列里
|
|
168
|
+
// 没有红(红在 DSH 里表示错误)。deepseek 色阶只到 `900`,深海军蓝是 `blue-950`;写错会静默透明。
|
|
169
|
+
function wsTint(count, index) {
|
|
170
|
+
const tints = ['neutral-500', 'amber-500', 'blue-950', 'green-500', 'neutral-250', 'deepseek-450', 'neutral-700'];
|
|
171
|
+
if (count > tints.length) index %= tints.length; // 超过 7 个才可能撞色:本机 6 个,不为它引入「加一个就全体换色」
|
|
172
|
+
return 'var(--dsw-static-' + tints[index % tints.length] + ')';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function trackWorkspaces(items) { // workspace 列表 → `owner`(会话 id → workspaceId,先到先得)与 `rank`(workspaceId → 列表位置,定色相)。
|
|
176
|
+
const owner = {};
|
|
177
|
+
const rank = {};
|
|
178
|
+
if (!Array.isArray(items)) return { owner, rank };
|
|
179
|
+
for (const item of items) {
|
|
180
|
+
if (!item || !isId(item.workspaceId)) continue;
|
|
181
|
+
if (!isId(rank[item.workspaceId])) rank[item.workspaceId] = Object.keys(rank).length;
|
|
182
|
+
for (const id of Array.isArray(item.sessionIds) ? item.sessionIds : []) if (isId(id) && !isId(owner[id])) owner[id] = item.workspaceId;
|
|
183
|
+
}
|
|
184
|
+
return { owner, rank };
|
|
185
|
+
}
|
|
186
|
+
function wsLabel(item) { // 徽章文案:优先工作区名,没有名才用路径最后一段;都没有就 `undefined`(不画徽章)。
|
|
187
|
+
if (isId(item.title)) return item.title;
|
|
188
|
+
const path = isId(item.path) ? item.path.replace(/\/+$/, '') : '';
|
|
189
|
+
return path ? path.slice(path.lastIndexOf('/') + 1) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function wheelTarget(list, event) { // 滚轮增量 → 横条该滚到的 `scrollLeft`;`undefined` = 这一下不由横条接管(横向手势 / 到端)。取舍见 README §7.6。
|
|
193
|
+
if (!list) return undefined;
|
|
194
|
+
const dy = Number(event.deltaY) || 0;
|
|
195
|
+
const dx = Number(event.deltaX) || 0;
|
|
196
|
+
// Shift+滚轮是浏览器原生的横向滚动、Ctrl+滚轮是缩放:都不该再由本横条叠一次。
|
|
197
|
+
if (dy === 0 || Math.abs(dy) <= Math.abs(dx) || event.shiftKey || event.ctrlKey) return undefined;
|
|
198
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientWidth || 400 : 1;
|
|
199
|
+
const max = Math.max(0, list.scrollWidth - list.clientWidth);
|
|
200
|
+
const target = Math.max(0, Math.min(max, list.scrollLeft + dy * unit));
|
|
201
|
+
return target === list.scrollLeft ? undefined : target;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 解码本设备的缓存载荷;任何异常/旧版本一律回落到「没有缓存」。`workspaces` 是后加的兜底归属表,
|
|
205
|
+
// 老缓存没有它——按空表读,不算坏缓存。
|
|
206
|
+
function decodeCache(raw) {
|
|
207
|
+
if (typeof raw !== 'string' || raw === '') return undefined;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(raw);
|
|
210
|
+
if (!parsed || parsed.v !== TABS_VERSION || !Array.isArray(parsed.ids)) return undefined;
|
|
211
|
+
return {
|
|
212
|
+
ids: sanitizeIds(parsed.ids),
|
|
213
|
+
seq: Number.isInteger(parsed.seq) && parsed.seq >= 0 ? parsed.seq : 0,
|
|
214
|
+
workspaces: sanitizeWorkspaces(parsed.workspaces),
|
|
215
|
+
};
|
|
216
|
+
} catch (_error) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function decodeLegacy(raw) { // 解码旧版按窗口分键的载荷(`{ v: 1, at, ids }`);只给迁移用。
|
|
222
|
+
if (typeof raw !== 'string' || raw === '') return [];
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(raw);
|
|
225
|
+
if (!parsed || parsed.v !== TABS_VERSION || !Array.isArray(parsed.ids)) return [];
|
|
226
|
+
return sanitizeIds(parsed.ids);
|
|
227
|
+
} catch (_error) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── 存储:全部包在 try 里,坏了就退化成内存态 ──
|
|
233
|
+
function safeStorage(kind) {
|
|
234
|
+
try {
|
|
235
|
+
const store = window[kind];
|
|
236
|
+
if (!store) return undefined;
|
|
237
|
+
store.setItem('dsh-tabs:probe', '1');
|
|
238
|
+
store.removeItem('dsh-tabs:probe');
|
|
239
|
+
return store;
|
|
240
|
+
} catch (_error) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readCache(local) {
|
|
246
|
+
if (!local) return undefined;
|
|
247
|
+
try {
|
|
248
|
+
return decodeCache(local.getItem(CACHE_KEY));
|
|
249
|
+
} catch (_error) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function writeCache(local, ids, seq, workspaces) {
|
|
255
|
+
if (!local) return;
|
|
256
|
+
try {
|
|
257
|
+
local.setItem(CACHE_KEY, JSON.stringify({ v: TABS_VERSION, seq, at: Date.now(), ids, workspaces }));
|
|
258
|
+
} catch (_error) {
|
|
259
|
+
/* 写失败(配额满 / 存储被禁)就放弃持久化,绝不打断交互 */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 一次性迁移:把旧版「每个窗口一份」的标签并集成共享集合的种子,然后删掉旧键——
|
|
264
|
+
// 老用户升级后第一次打开页面时,标签不该凭空消失。
|
|
265
|
+
function migrateLegacy(local) {
|
|
266
|
+
if (!local) return [];
|
|
267
|
+
const seeded = [];
|
|
268
|
+
const stale = [];
|
|
269
|
+
try {
|
|
270
|
+
for (let i = 0; i < local.length; i += 1) {
|
|
271
|
+
const key = local.key(i);
|
|
272
|
+
if (typeof key !== 'string' || key.indexOf(LEGACY_PREFIX) !== 0) continue;
|
|
273
|
+
stale.push(key);
|
|
274
|
+
for (const id of decodeLegacy(local.getItem(key))) if (seeded.indexOf(id) < 0) seeded.push(id);
|
|
275
|
+
}
|
|
276
|
+
for (const key of stale) local.removeItem(key);
|
|
277
|
+
} catch (_error) {
|
|
278
|
+
/* 见上 */
|
|
279
|
+
}
|
|
280
|
+
return seeded;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── 共享集合的同步客户端 ──
|
|
284
|
+
|
|
285
|
+
function createSync(options) { // 建一个同步客户端:`{ local?(缺失时纯内存), warnOnce?(同一原因只报一次), fetch?(缺失时直接本地模式) }`。
|
|
286
|
+
const local = options.local;
|
|
287
|
+
const fetchImpl = options.fetch;
|
|
288
|
+
const listeners = new Set();
|
|
289
|
+
const cached = readCache(local);
|
|
290
|
+
const migrated = cached ? [] : migrateLegacy(local);
|
|
291
|
+
let ids = cached ? cached.ids : migrated;
|
|
292
|
+
let seq = cached ? cached.seq : 0;
|
|
293
|
+
let workspaces = cached ? cached.workspaces : {}; // 会话 → workspaceId 的**兜底**归属表(宿主冗余下来的那份)。真相是 workspace 列表。
|
|
294
|
+
// 本机有旧版标签但还没有同步缓存 = 升级上来的第一次。只在「首次接触 + 宿主一个标签都没有」时认领,
|
|
295
|
+
// 否则旧标签会被宿主的空集合盖掉;拿陈旧同步缓存去认领会把别的设备关掉的标签复活。
|
|
296
|
+
|
|
297
|
+
const claimable = !cached && ids.length > 0;
|
|
298
|
+
let contacted = false;
|
|
299
|
+
// 迁移种子要立刻落缓存:否则「只迁移过一次、还没有任何操作」的页面刷新后会再迁移一次(旧键已删,读不到 = 标签凭空消失)。
|
|
300
|
+
if (!cached && ids.length > 0) writeCache(local, ids, seq, workspaces);
|
|
301
|
+
let pending = []; // 尚未被宿主接受的本地操作,按发生顺序补发。
|
|
302
|
+
let mode = fetchImpl ? 'unknown' : 'local'; // local = 不做网络(宿主端点不可用 / 没有 fetch);remote = 同步中。
|
|
303
|
+
let chain = Promise.resolve(); // 所有网络请求串行,避免 GET 与 POST 互相覆盖。
|
|
304
|
+
let timer = null;
|
|
305
|
+
let started = false;
|
|
306
|
+
let stopped = false;
|
|
307
|
+
|
|
308
|
+
const snapshot = () => ids;
|
|
309
|
+
const workspacesSnapshot = () => Object.assign({}, workspaces); // 兜底归属表(拷贝):`{ [会话Id]: workspaceId }`。没有归属时是 `{}`,不是 `undefined`。
|
|
310
|
+
|
|
311
|
+
function subscribe(listener) {
|
|
312
|
+
listeners.add(listener);
|
|
313
|
+
return () => {
|
|
314
|
+
listeners.delete(listener);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function notify() {
|
|
319
|
+
writeCache(local, ids, seq, workspaces);
|
|
320
|
+
for (const listener of listeners) listener();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 接受宿主的一份状态。不做「序号更老就丢弃」的判定:本客户端网络请求全部串行(`chain`),答复本来就
|
|
324
|
+
// 按序到达;而宿主状态文件被删/重建时 seq 会从头开始,拿旧 seq 当闸门会让客户端永远拒绝新状态。
|
|
325
|
+
function adopt(next) {
|
|
326
|
+
if (!next || typeof next !== 'object' || !Array.isArray(next.tabs)) return;
|
|
327
|
+
const tabs = sanitizeIds(next.tabs);
|
|
328
|
+
const nextSeq = Number.isInteger(next.seq) && next.seq >= 0 ? next.seq : seq;
|
|
329
|
+
// 老宿主不返回 `workspaceByTab`:按空表读,静默当作没有 fallback(不告警)。
|
|
330
|
+
const nextWorkspaces = applyOps(tabs, [], next.workspaceByTab).workspaces;
|
|
331
|
+
if (sameList(tabs, ids) && sameWorkspaces(workspaces, nextWorkspaces)) {
|
|
332
|
+
if (nextSeq > seq) {
|
|
333
|
+
seq = nextSeq;
|
|
334
|
+
writeCache(local, ids, seq, workspaces);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
ids = tabs;
|
|
339
|
+
workspaces = nextWorkspaces;
|
|
340
|
+
seq = nextSeq;
|
|
341
|
+
notify();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function queue(task) {
|
|
345
|
+
chain = chain.then(task).catch(() => {});
|
|
346
|
+
return chain;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function markLocal(message) {
|
|
350
|
+
if (mode === 'local') return;
|
|
351
|
+
mode = 'local';
|
|
352
|
+
if (timer) {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
timer = null;
|
|
355
|
+
}
|
|
356
|
+
options.warnOnce('sync', message);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function call(method, body) {
|
|
360
|
+
try {
|
|
361
|
+
const init = { method, credentials: 'same-origin', cache: 'no-store' };
|
|
362
|
+
if (body !== undefined) {
|
|
363
|
+
init.headers = { 'content-type': 'application/json' };
|
|
364
|
+
init.body = JSON.stringify(body);
|
|
365
|
+
}
|
|
366
|
+
const response = await fetchImpl(STATE_API, init);
|
|
367
|
+
if (response.status === 404 || response.status === 405) {
|
|
368
|
+
markLocal('宿主没有 dsh-tabs 的同步端点(host 半边未加载):退回本设备本地模式。');
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
options.warnOnce('http-' + response.status, '同步端点返回 HTTP ' + response.status + ':暂时只保留本地标签。');
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const payload = await response.json();
|
|
376
|
+
if (!payload || payload.ok !== true || !payload.state) {
|
|
377
|
+
options.warnOnce('payload', '同步端点返回了无法识别的载荷:暂时只保留本地标签。');
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
mode = 'remote';
|
|
381
|
+
return payload;
|
|
382
|
+
} catch (_error) {
|
|
383
|
+
options.warnOnce('network', '连不上宿主同步端点:先只保留本地标签,恢复后会自动补发。');
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function refresh() {
|
|
389
|
+
if (stopped || mode === 'local' || pending.length > 0) return;
|
|
390
|
+
const payload = await call('GET');
|
|
391
|
+
if (!payload || pending.length > 0) return;
|
|
392
|
+
// 升级后的首次接触:宿主还是空的,就把本机旧标签认领上去(只做一次)。
|
|
393
|
+
if (!contacted) {
|
|
394
|
+
contacted = true;
|
|
395
|
+
if (claimable && payload.state.tabs.length === 0) {
|
|
396
|
+
for (const id of ids) pending.push({ op: 'open', id });
|
|
397
|
+
schedule();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
adopt(payload.state);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function flush() {
|
|
405
|
+
let latest;
|
|
406
|
+
while (!stopped && mode !== 'local' && pending.length > 0) {
|
|
407
|
+
const batch = pending.slice();
|
|
408
|
+
const payload = await call('POST', { v: TABS_VERSION, ops: batch });
|
|
409
|
+
if (!payload) return;
|
|
410
|
+
pending = pending.slice(batch.length);
|
|
411
|
+
latest = payload;
|
|
412
|
+
}
|
|
413
|
+
if (!stopped && mode !== 'local' && latest && pending.length === 0) adopt(latest.state);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function syncNow() { // 一次「把本地与宿主对齐」的动作:有待补发的先补发,否则拉一次状态。
|
|
417
|
+
if (stopped || mode === 'local') return chain;
|
|
418
|
+
return queue(() => (pending.length > 0 ? flush() : refresh()));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function schedule() {
|
|
422
|
+
if (stopped || mode === 'local') return;
|
|
423
|
+
queue(flush);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function mutate(op) { // 本地操作:先乐观落到界面,再进补发队列。
|
|
427
|
+
if (stopped) return;
|
|
428
|
+
const result = applyOps(ids, [op], workspaces);
|
|
429
|
+
if (!result.changed) return;
|
|
430
|
+
ids = result.ids;
|
|
431
|
+
workspaces = result.workspaces;
|
|
432
|
+
notify();
|
|
433
|
+
if (mode === 'local') return;
|
|
434
|
+
pending.push(op);
|
|
435
|
+
schedule();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function poll() {
|
|
439
|
+
timer = setTimeout(() => {
|
|
440
|
+
timer = null;
|
|
441
|
+
if (stopped || mode === 'local') return;
|
|
442
|
+
syncNow().then(() => {
|
|
443
|
+
if (!stopped && mode !== 'local') poll();
|
|
444
|
+
});
|
|
445
|
+
}, POLL_MS);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const onFocus = () => {
|
|
449
|
+
syncNow();
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
function start() {
|
|
453
|
+
if (started || stopped) return;
|
|
454
|
+
started = true;
|
|
455
|
+
if (mode === 'local') return;
|
|
456
|
+
if (typeof window.addEventListener === 'function') window.addEventListener('focus', onFocus);
|
|
457
|
+
syncNow();
|
|
458
|
+
poll();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function stop() {
|
|
462
|
+
stopped = true;
|
|
463
|
+
if (timer) {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
timer = null;
|
|
466
|
+
}
|
|
467
|
+
if (typeof window.removeEventListener === 'function') window.removeEventListener('focus', onFocus);
|
|
468
|
+
listeners.clear();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const open = (id, workspaceId) => mutate({ op: 'open', id, workspaceId }); // `workspaceId` 可选:带的只是兜底归属(真相是 workspace 列表),非法值忽略。
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
snapshot,
|
|
475
|
+
workspaces: workspacesSnapshot,
|
|
476
|
+
subscribe,
|
|
477
|
+
open,
|
|
478
|
+
close: (id) => mutate({ op: 'close', id }),
|
|
479
|
+
start,
|
|
480
|
+
stop,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ───── 样式:只走主题令牌,无硬编码色值;只选本插件自己的 data 属性 / 类名 ─────
|
|
485
|
+
// 一条规则一行;`--dsw-*` 之外只有 transparent / currentColor / 色条色值(wsTint 的输出)。
|
|
486
|
+
// 颜色必须写成 `var(--dsw-…)` 字面量:P0-8 的发布门禁就是按源码里的字面量核验的,别抽成拼接函数。
|
|
487
|
+
const FOCUS = 'outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px';
|
|
488
|
+
const CSS = [
|
|
489
|
+
'[data-dsh-tabs]{box-sizing:border-box;padding-top:var(--dsh-tabs-h,38px)}',
|
|
490
|
+
'.dshtabs{position:absolute;top:0;left:0;right:0;box-sizing:border-box;display:flex;align-items:center;gap:4px;padding:4px 8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);background:var(--dsw-specific-sidebar-fill);border-bottom:0.5px solid var(--dsw-alias-border-l2)}',
|
|
491
|
+
'.dshtabs-list{display:flex;align-items:center;gap:4px;flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;overscroll-behavior-x:contain}',
|
|
492
|
+
'.dshtabs-list::-webkit-scrollbar{display:none}',
|
|
493
|
+
'.dshtabs-tab{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:200px;padding:4px 6px 4px 10px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer}',
|
|
494
|
+
'.dshtabs-tab:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
495
|
+
'.dshtabs-tab[aria-selected="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-active);box-shadow:inset 0 0 0 0.5px var(--dsw-alias-border-l2),inset 0 -2px 0 0 var(--dsw-alias-state-business-primary)}',
|
|
496
|
+
'.dshtabs-tab:focus-visible{' + FOCUS + '}',
|
|
497
|
+
'.dshtabs-tab[data-missing],.dshtabs-tab[aria-disabled="true"]{color:var(--dsw-alias-state-idle-primary)}',
|
|
498
|
+
'.dshtabs-tab[aria-disabled="true"]{cursor:default}',
|
|
499
|
+
'.dshtabs-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
500
|
+
'.dshtabs-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
|
|
501
|
+
'.dshtabs-dot[data-kind="running"]{background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary));animation:dshtabs-pulse 1.4s ease-in-out infinite}',
|
|
502
|
+
'.dshtabs-dot[data-kind="pending"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
503
|
+
'.dshtabs-dot[data-kind="unread"]{background:var(--dsw-alias-state-success-primary)}',
|
|
504
|
+
// ── workspace 分组(P2-1):色条是标签**左内侧**的独立竖边,与状态点是两个位置 ──
|
|
505
|
+
// 绝不与状态点共用矩形:7px 的圆点里塞一条 2px 方线会变成「蓝底灰」这种重影,看久了眼花。
|
|
506
|
+
// 没有 `data-ws` 的标签不画色条,外观与引入分组之前一致。
|
|
507
|
+
'.dshtabs-wsbar{flex:none;width:2px;align-self:stretch;margin:3px 0;border-radius:1px;background:var(--dshtabs-ws)}',
|
|
508
|
+
// 连成组:后面的成员只靠 4px(原来色条所占宽度)贴上去,色条自身不变宽也不断缝。
|
|
509
|
+
'.dshtabs-tab[data-ws-join="true"]{margin-left:-10px}',
|
|
510
|
+
'.dshtabs-ws{flex:none;max-width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;border-radius:4px;font-size:11px;line-height:16px;opacity:.7;background:var(--dsw-alias-interactive-bg-hover)}',
|
|
511
|
+
'@keyframes dshtabs-pulse{0%,100%{opacity:1}50%{opacity:.35}}',
|
|
512
|
+
'@media (prefers-reduced-motion:reduce){.dshtabs-dot[data-kind="running"]{animation:none}}',
|
|
513
|
+
'.dshtabs-close{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;line-height:1;cursor:pointer;opacity:0}',
|
|
514
|
+
'.dshtabs-tab:hover .dshtabs-close,.dshtabs-tab:focus-within .dshtabs-close,.dshtabs-tab[aria-selected="true"] .dshtabs-close{opacity:.7}',
|
|
515
|
+
'.dshtabs-close:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover)}',
|
|
516
|
+
// ── P2-2 窄屏:横条只剩「当前会话 + ≡N」,全部标签进抽屉 ──
|
|
517
|
+
// 桌面端这些规则一条都不生效(菜单按钮 display:none、媒体查询不命中),外观与引入之前一致。
|
|
518
|
+
'.dshtabs-menu{display:none;flex:none;align-items:center;gap:5px;height:26px;padding:0 8px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1;cursor:pointer}',
|
|
519
|
+
'.dshtabs-menu:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
520
|
+
'.dshtabs-menu:focus-visible{' + FOCUS + '}',
|
|
521
|
+
// 展开时按钮**自己变成按下态**:抽屉不再盖住横条,用户看得见「再点一次就收起」这个对称动作。
|
|
522
|
+
'.dshtabs-menu[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}',
|
|
523
|
+
'.dshtabs-menu-count{font-variant-numeric:tabular-nums}',
|
|
524
|
+
// 当前会话不在集合里时的只读兜底标题(宽屏不显示):外观与选中标签一致,但它不是标签。
|
|
525
|
+
'.dshtabs-stray{display:none;flex:1 1 auto;min-width:0;align-items:center;gap:6px;padding:6px 8px;border-radius:6px;background:var(--dsw-alias-interactive-bg-active);box-shadow:inset 0 0 0 0.5px var(--dsw-alias-border-l2),inset 0 -2px 0 0 var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}',
|
|
526
|
+
// 遮罩与面板一律 `fixed`。**不写「退化成 absolute」的伪退路**:我们的面板是横条(`position:absolute`)
|
|
527
|
+
// 的子节点,absolute 的包含块会变成那条 47px 高的横条,面板只会被压成一条——比 fixed 失效更糟。
|
|
528
|
+
// 真遇到带 transform 的祖先(本机实测没有),正确解法是把面板挂到 body,而没有 portal 做不到;
|
|
529
|
+
// 那时应该整块回滚,而不是留一条看起来在兜底、实际会更坏的路径。
|
|
530
|
+
'.dshtabs-scrim{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-1);z-index:60}',
|
|
531
|
+
'.dshtabs-sheet{position:fixed;left:0;right:0;bottom:0;z-index:61;display:flex;flex-direction:column;max-height:64vh;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-top:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-panel) var(--dsw-radius-panel) 0 0;box-shadow:var(--dsw-elevation-panel);padding-bottom:calc(8px + env(safe-area-inset-bottom,0px));font-size:13px;line-height:20px}',
|
|
532
|
+
'.dshtabs-sheet:focus{outline:none}', // 焦点落在面板上只是为了让 Esc 有接收者,不画聚焦环
|
|
533
|
+
'.dshtabs-sheet-head{flex:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
534
|
+
'.dshtabs-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
535
|
+
// 显式关闭:手机上「点遮罩」不是一个看得见的动作,面板得自带一个「收起」按钮。
|
|
536
|
+
'.dshtabs-sheet-close{flex:none;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:36px;margin:-8px -8px -8px 0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:16px;line-height:1;cursor:pointer}',
|
|
537
|
+
'.dshtabs-sheet-close:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
538
|
+
'.dshtabs-sheet-close:focus-visible{' + FOCUS + '}',
|
|
539
|
+
'.dshtabs-sheet-rows{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}',
|
|
540
|
+
'.dshtabs-row{display:flex;align-items:stretch;border-top:0.5px solid var(--dsw-alias-border-l2)}',
|
|
541
|
+
'.dshtabs-row[aria-current="true"]{background:var(--dsw-alias-interactive-bg-active)}',
|
|
542
|
+
'.dshtabs-row-main{flex:1 1 auto;display:flex;align-items:center;gap:10px;min-width:0;min-height:56px;padding:9px 4px 9px 12px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
543
|
+
'.dshtabs-row-main:focus-visible{' + FOCUS + '}',
|
|
544
|
+
'.dshtabs-row-bar{flex:none;width:2px;height:30px;border-radius:1px;background:var(--dshtabs-ws)}',
|
|
545
|
+
'.dshtabs-row-text{flex:1 1 auto;min-width:0;display:block}',
|
|
546
|
+
'.dshtabs-row-title{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}',
|
|
547
|
+
'.dshtabs-row-meta{display:block;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
548
|
+
'.dshtabs-row-close{flex:none;align-self:center;width:44px;height:44px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;line-height:1;cursor:pointer}',
|
|
549
|
+
'.dshtabs-row-close:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
550
|
+
'.dshtabs-row-close:focus-visible{' + FOCUS + '}',
|
|
551
|
+
// 窄屏覆盖:**必须排在最后**——媒体查询与普通规则同优先级,靠源码顺序决胜。
|
|
552
|
+
// 隐藏非当前标签(`display:none` 同时把它们移出可访问性树与 Tab 次序)、隐藏工作区徽章与 ×
|
|
553
|
+
// (手机上一格约 260px,色条已经表达归属;× 移到抽屉行里,避免 28px 的误触)。
|
|
554
|
+
// 写法是连续字符串拼接、不是内层数组:P0-8 的样式门禁按 `const CSS = [...]` 的字面量取证,
|
|
555
|
+
// 内层再来一次「数组 + join」会把那段正则的边界抢走(门禁静默变成只检查半张表)。
|
|
556
|
+
'@media (max-width:' + NARROW_PX + 'px){'
|
|
557
|
+
// 横条整体挪到**底部**(P2-4):宿主 0.2.2 起在 innerWidth<768 时把侧边栏降级成离屏抽屉,
|
|
558
|
+
// 唯一入口是左上角 (12,11) 那颗浮动按钮;顶部横条会把它压死(实测该坐标最上层是我们的 `.dshtabs-dot`)。
|
|
559
|
+
// 底部横条既把顶部整条让给宿主,也更接近手机的标签栏习惯。`[data-dsh-tabs]` 的避让从 padding-top 换到 padding-bottom。
|
|
560
|
+
+ '[data-dsh-tabs]{padding-top:0;padding-bottom:var(--dsh-tabs-h,38px)}'
|
|
561
|
+
+ '.dshtabs{top:auto;bottom:0;border-top:0.5px solid var(--dsw-alias-border-l2);border-bottom:0}'
|
|
562
|
+
// 横条固定 48px、子项拉伸填满:手机上整条都是可点区域(原来的 32px 标签 + 26px 按钮低于 44px 触区)。
|
|
563
|
+
// 高度加安全区(刘海屏/手势条),内容仍占 48px:border-box 下 height 含 padding-bottom。
|
|
564
|
+
+ '.dshtabs{height:calc(48px + env(safe-area-inset-bottom,0px));padding:0 8px env(safe-area-inset-bottom,0px);gap:6px}'
|
|
565
|
+
+ '.dshtabs-list{align-items:stretch}'
|
|
566
|
+
+ '.dshtabs-tab[aria-selected="true"]{flex:1 1 auto;max-width:none;align-self:stretch;padding:8px}'
|
|
567
|
+
+ '.dshtabs-stray{align-self:stretch;padding:8px}'
|
|
568
|
+
+ '.dshtabs-menu{align-self:stretch;height:auto;min-width:44px}'
|
|
569
|
+
// 抽屉**不盖住横条**(P2-5):面板与遮罩都从横条上沿往上长,横条保持可见可点。
|
|
570
|
+
// 这样「再点一次 ≡N 收起」与「点重新回到当前会话」都是同一套可见控件,收回动作不再靠猜。
|
|
571
|
+
+ '.dshtabs-scrim{bottom:var(--dsh-tabs-h,48px)}'
|
|
572
|
+
+ '.dshtabs-sheet{bottom:var(--dsh-tabs-h,48px);max-height:calc(64vh - var(--dsh-tabs-h,48px))}'
|
|
573
|
+
+ '.dshtabs-tab:not([aria-selected="true"]){display:none}'
|
|
574
|
+
+ '.dshtabs-ws{display:none}'
|
|
575
|
+
+ '.dshtabs-close{display:none}'
|
|
576
|
+
+ '.dshtabs-menu{display:inline-flex}'
|
|
577
|
+
+ '.dshtabs-stray{display:inline-flex}'
|
|
578
|
+
// 没有选中标签时整条列表都是空的(其余标签已被上一条隐藏),别让它继续占着 flex 份额,
|
|
579
|
+
// 否则兜底标题只能拿到半条宽度。`:has` 不支持的浏览器只是少这条优化,布局仍然正确。
|
|
580
|
+
+ '.dshtabs-list:not(:has(.dshtabs-tab[aria-selected="true"])){display:none}'
|
|
581
|
+
+ '}',
|
|
582
|
+
].join('');
|
|
583
|
+
|
|
584
|
+
const EMPTY = {};
|
|
585
|
+
const EMPTY_LIST = [];
|
|
586
|
+
const noopHook = () => undefined;
|
|
587
|
+
|
|
588
|
+
// ── 软键盘(P2-6):让键盘**压缩布局视口**,而不是把可视视口平移出去 ──
|
|
589
|
+
// 背景(2026-11-09 真机实测,Android + Chromium,360×649):默认 `interactive-widget=resizes-visual` 时,
|
|
590
|
+
// 键盘只缩**可视视口**,布局视口仍是 649,浏览器把可视视口往上平移去露出输入框——页面只有 649 高,
|
|
591
|
+
// 平移后底部露出「页面之外」的一片空白,位置正好在标签栏与键盘之间(用户原话:键盘上方有多余的空白)。
|
|
592
|
+
// 把 viewport meta 加上 `interactive-widget=resizes-content` 后,布局视口跟着缩到 378,
|
|
593
|
+
// `html/body/#root` 的 100% 一起缩,标签栏落到 330–378、紧贴键盘,空白消失。
|
|
594
|
+
// 桌面无感(没有软键盘时该指令不生效)。只在宿主**没自己声明**时补,绝不覆盖宿主的写法。
|
|
595
|
+
function withResizesContent(content) {
|
|
596
|
+
const text = typeof content === 'string' ? content.trim() : '';
|
|
597
|
+
if (/(^|[,\s])interactive-widget\s*=/.test(text)) return text; // 宿主已经表过态
|
|
598
|
+
if (text === '') return 'interactive-widget=resizes-content';
|
|
599
|
+
return text.replace(/\s*$/, '') + ', interactive-widget=resizes-content';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function ensureViewportMeta(doc) {
|
|
603
|
+
const root = doc || (typeof document === 'undefined' ? null : document);
|
|
604
|
+
if (!root || typeof root.querySelector !== 'function') return false;
|
|
605
|
+
const meta = root.querySelector('meta[name="viewport"]');
|
|
606
|
+
if (!meta || typeof meta.getAttribute !== 'function' || typeof meta.setAttribute !== 'function') return false;
|
|
607
|
+
const current = meta.getAttribute('content');
|
|
608
|
+
const next = withResizesContent(current);
|
|
609
|
+
if (next === (current || '')) return false;
|
|
610
|
+
meta.setAttribute('content', next);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── 组件 ──
|
|
615
|
+
function TabBar(props) {
|
|
616
|
+
const deps = props.__tabs;
|
|
617
|
+
const warnOnce = deps.warnOnce;
|
|
618
|
+
const useList = typeof props.useSessions === 'function' ? props.useSessions : noopHook;
|
|
619
|
+
const useStatus = typeof props.useSessionStatus === 'function' ? props.useSessionStatus : noopHook;
|
|
620
|
+
// 缺 `useWorkspaces` 是**正常状态**(老宿主):整条归属链路退化成原样,不告警。
|
|
621
|
+
const useWs = typeof props.useWorkspaces === 'function' ? props.useWorkspaces : noopHook;
|
|
622
|
+
|
|
623
|
+
const byId = useList((state) => state.byId) || EMPTY;
|
|
624
|
+
const statuses = useStatus((state) => state);
|
|
625
|
+
const activeId = React.useMemo(() => activeSession(byId), [byId]);
|
|
626
|
+
// 归属的唯一真相:`WorkspaceView.sessionIds`(`SessionSummary` 上没有 workspaceId,不能按 cwd 猜)。
|
|
627
|
+
// 宿主还没 ready 时 `items` 是空的 → 两张表都空,后面的渲染自然退回原样。
|
|
628
|
+
const wsItems = useWs((state) => state && state.items) || EMPTY_LIST;
|
|
629
|
+
const wsIndex = React.useMemo(() => trackWorkspaces(wsItems), [wsItems]);
|
|
630
|
+
|
|
631
|
+
// 集合来自同步客户端;本地操作与宿主推送都通过 subscribe → setIds 汇入同一条渲染路径。
|
|
632
|
+
const [ids, setIds] = React.useState(deps.sync.snapshot());
|
|
633
|
+
const [fallback, setFallback] = React.useState(deps.sync.workspaces);
|
|
634
|
+
const [focusId, setFocusId] = React.useState(null);
|
|
635
|
+
const [anchor, setAnchor] = React.useState('probing');
|
|
636
|
+
const [sheetOpen, setSheetOpen] = React.useState(false); // P2-2:窄屏抽屉。宽屏下打开入口是 display:none,正常不会为 true。
|
|
637
|
+
// 窄屏标志只用于**键盘语义**(横条外观由 CSS 媒体查询负责)。两者共用 NARROW_QUERY 这个来源。
|
|
638
|
+
const [narrow, setNarrow] = React.useState(() => {
|
|
639
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
640
|
+
const query = window.matchMedia(NARROW_QUERY);
|
|
641
|
+
return !!(query && query.matches);
|
|
642
|
+
});
|
|
643
|
+
const [, forceRender] = React.useState(0);
|
|
644
|
+
const barRef = React.useRef(null);
|
|
645
|
+
const listRef = React.useRef(null);
|
|
646
|
+
const menuRef = React.useRef(null);
|
|
647
|
+
const sheetRef = React.useRef(null);
|
|
648
|
+
|
|
649
|
+
// `uiWorkspace` 是异步出现的:等它到位时把横条重渲染一次,好让禁用态自动解除。
|
|
650
|
+
React.useEffect(() => {
|
|
651
|
+
const listener = () => forceRender((count) => count + 1);
|
|
652
|
+
deps.workspace.listeners.add(listener);
|
|
653
|
+
return () => deps.workspace.listeners.delete(listener);
|
|
654
|
+
}, []);
|
|
655
|
+
|
|
656
|
+
// 订阅共享集合:别的设备关掉/打开一条会话后,这里跟着重渲染(归属表变了也算)。
|
|
657
|
+
React.useEffect(
|
|
658
|
+
() =>
|
|
659
|
+
deps.sync.subscribe(() => {
|
|
660
|
+
setIds(deps.sync.snapshot());
|
|
661
|
+
setFallback(deps.sync.workspaces());
|
|
662
|
+
}),
|
|
663
|
+
[],
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
// 优先级:本机 workspace 列表反查 > 宿主的兜底归属表 > 无归属(不画任何东西)。
|
|
667
|
+
const wsOf = (id) => wsIndex.owner[id] || fallback[id];
|
|
668
|
+
|
|
669
|
+
const workspace = deps.workspace.current;
|
|
670
|
+
const canNavigate = !!workspace;
|
|
671
|
+
|
|
672
|
+
// P0-11:把「缺了什么」说清楚,且同一条只说一次。
|
|
673
|
+
React.useEffect(() => {
|
|
674
|
+
if (typeof props.useSessions !== 'function') {
|
|
675
|
+
warnOnce('sessions', 'useSessions 缺失:读不到会话清单,标签栏不渲染。');
|
|
676
|
+
}
|
|
677
|
+
if (typeof props.useSessionStatus !== 'function') {
|
|
678
|
+
warnOnce('status', 'useSessionStatus 缺失:不显示状态点(不猜状态)。');
|
|
679
|
+
}
|
|
680
|
+
if (!canNavigate) {
|
|
681
|
+
warnOnce('uiWorkspace', 'uiWorkspace 暂不可用:点标签切换与 + 新建先禁用,服务出现后自动启用。');
|
|
682
|
+
}
|
|
683
|
+
}, [canNavigate, props.useSessions, props.useSessionStatus]);
|
|
684
|
+
|
|
685
|
+
// 「打开过就占」:当前主区会话一定在标签集合里(P0-2 / P0-5)。
|
|
686
|
+
// 本地立即生效,并经同步队列写到宿主 —— 另一台设备的标签栏也会出现它。
|
|
687
|
+
// 顺手把归属冗余给宿主:别的设备 workspace 列表还没到时,靠这份兜底也能分组。
|
|
688
|
+
React.useEffect(() => {
|
|
689
|
+
if (activeId) deps.sync.open(activeId, wsIndex.owner[activeId]);
|
|
690
|
+
}, [activeId, wsIndex]);
|
|
691
|
+
|
|
692
|
+
// 软键盘(P2-6):只在**这一条**上碰宿主的 viewport meta,且只在宿主没声明 `interactive-widget` 时补。
|
|
693
|
+
// 不这么做的话,手机上开键盘时键盘与标签栏之间会露出一条「页面之外」的空白(真机实测见函数注释)。
|
|
694
|
+
React.useEffect(() => {
|
|
695
|
+
ensureViewportMeta();
|
|
696
|
+
}, []);
|
|
697
|
+
|
|
698
|
+
// 布局避让(P0-10):官方 data 属性定位框架节点 → 打自己的属性 + CSS 变量。
|
|
699
|
+
React.useEffect(() => {
|
|
700
|
+
let frame = null;
|
|
701
|
+
let timer = null;
|
|
702
|
+
let tries = 0;
|
|
703
|
+
let observer = null;
|
|
704
|
+
|
|
705
|
+
const locate = () => {
|
|
706
|
+
const own = barRef.current;
|
|
707
|
+
const layer = own && own.closest ? own.closest('[data-shell-overlay]') : null;
|
|
708
|
+
return layer ? layer.parentElement : null;
|
|
709
|
+
};
|
|
710
|
+
const publish = () => {
|
|
711
|
+
const own = barRef.current;
|
|
712
|
+
if (!frame || !own) return;
|
|
713
|
+
const px = Math.round(own.getBoundingClientRect().height);
|
|
714
|
+
if (px > 0) frame.style.setProperty('--dsh-tabs-h', px + 'px');
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const attach = () => {
|
|
718
|
+
frame = locate();
|
|
719
|
+
if (!frame) {
|
|
720
|
+
tries += 1;
|
|
721
|
+
if (tries <= RETRY_MAX) {
|
|
722
|
+
timer = setTimeout(attach, RETRY_MS);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
warn('拿不到 [data-shell-overlay] 的框架节点,标签栏不渲染(绝不静默重叠到原生 UI 上)。');
|
|
726
|
+
setAnchor('failed');
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
frame.setAttribute('data-dsh-tabs', '');
|
|
730
|
+
frame.style.setProperty('--dsh-tabs-h', FALLBACK_H);
|
|
731
|
+
setAnchor('ok');
|
|
732
|
+
publish();
|
|
733
|
+
if (typeof ResizeObserver === 'function') {
|
|
734
|
+
observer = new ResizeObserver(publish);
|
|
735
|
+
observer.observe(barRef.current);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
attach();
|
|
740
|
+
return () => {
|
|
741
|
+
if (timer) clearTimeout(timer);
|
|
742
|
+
if (observer) observer.disconnect();
|
|
743
|
+
if (frame) {
|
|
744
|
+
frame.removeAttribute('data-dsh-tabs');
|
|
745
|
+
frame.style.removeProperty('--dsh-tabs-h');
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}, []);
|
|
749
|
+
|
|
750
|
+
// ── 抽屉(P2-2):焦点、断点、定位自检。三个 effect 都只在开了抽屉之后才有事可做 ──
|
|
751
|
+
|
|
752
|
+
// 焦点:打开时送进抽屉(优先落在当前会话那一行),关闭时还给 ≡ 按钮。
|
|
753
|
+
// 不还给按钮的话,键盘用户按完 Esc 会掉回页面顶端,得重新 Tab 一圈才回得来。
|
|
754
|
+
React.useEffect(() => {
|
|
755
|
+
if (!sheetOpen) return undefined;
|
|
756
|
+
const node = sheetRef.current;
|
|
757
|
+
if (node) {
|
|
758
|
+
const row = typeof node.querySelector === 'function' ? node.querySelector('.dshtabs-row[aria-current="true"] .dshtabs-row-main') : null;
|
|
759
|
+
const target = row || node;
|
|
760
|
+
if (target && typeof target.focus === 'function') target.focus();
|
|
761
|
+
}
|
|
762
|
+
return () => {
|
|
763
|
+
const trigger = menuRef.current;
|
|
764
|
+
// 宽屏下 ≡ 是 display:none:聚焦一个隐藏元素只会把焦点丢到 body。量得到布局就只在可见时归还。
|
|
765
|
+
const usable = !trigger || typeof trigger.getClientRects !== 'function' || trigger.getClientRects().length > 0;
|
|
766
|
+
if (trigger && usable && typeof trigger.focus === 'function') trigger.focus();
|
|
767
|
+
};
|
|
768
|
+
}, [sheetOpen]);
|
|
769
|
+
|
|
770
|
+
// Esc 再挂一层在 document 上:真实浏览器里点面板内的不可聚焦区域(标题栏、空态、边距)会让
|
|
771
|
+
// `activeElement` 掉回 body,此时面板上的 onKeyDown 收不到键——那是最容易按 Esc 起不到作用的时刻。
|
|
772
|
+
// 面板自己的 onKeyDown 仍保留(键盘用户的常规路径,也是离线测试替身唯一能派发的那条)。
|
|
773
|
+
React.useEffect(() => {
|
|
774
|
+
if (!sheetOpen || typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined;
|
|
775
|
+
const onKey = (event) => {
|
|
776
|
+
if (event.key === 'Escape' || event.key === 'Esc') setSheetOpen(false);
|
|
777
|
+
};
|
|
778
|
+
document.addEventListener('keydown', onKey);
|
|
779
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
780
|
+
}, [sheetOpen]);
|
|
781
|
+
|
|
782
|
+
// 断点变化:同步键盘语义用的 `narrow`,并在视口变宽时收抽屉。
|
|
783
|
+
// 没有 matchMedia(老宿主)就不挂——那样抽屉只能靠 Esc/遮罩/选中关,键盘语义保持桌面那套,不会卡死。
|
|
784
|
+
React.useEffect(() => {
|
|
785
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
|
|
786
|
+
const query = window.matchMedia(NARROW_QUERY);
|
|
787
|
+
if (!query || typeof query.addEventListener !== 'function') return undefined;
|
|
788
|
+
const onChange = () => {
|
|
789
|
+
setNarrow(query.matches);
|
|
790
|
+
if (!query.matches) setSheetOpen(false); // 视口变宽就收抽屉:宽屏上不该留一块浮层
|
|
791
|
+
};
|
|
792
|
+
query.addEventListener('change', onChange);
|
|
793
|
+
return () => query.removeEventListener('change', onChange);
|
|
794
|
+
}, []);
|
|
795
|
+
|
|
796
|
+
// 焦点落点:优先用户刚移过去的那个,其次当前会话,最后第一个。
|
|
797
|
+
// **窄屏例外**:其余标签都是 `display:none`(不可聚焦),roving 只能落在唯一可见的那条上,
|
|
798
|
+
// 否则 tablist 会把唯一的 Tab 停靠点交给一个看不见的标签,Delete/Enter 也会作用在看不见的那条。
|
|
799
|
+
const focusable = focusId && ids.indexOf(focusId) >= 0 ? focusId : activeId;
|
|
800
|
+
const rovingId = narrow
|
|
801
|
+
? activeId && ids.indexOf(activeId) >= 0
|
|
802
|
+
? activeId
|
|
803
|
+
: ids[0]
|
|
804
|
+
: focusable && ids.indexOf(focusable) >= 0
|
|
805
|
+
? focusable
|
|
806
|
+
: ids[0];
|
|
807
|
+
|
|
808
|
+
const activate = (id) => {
|
|
809
|
+
const target = deps.workspace.current; // 调用时**重新读**:服务可能挂载之后才出现
|
|
810
|
+
if (!target || id === activeId) return; // 没有服务、或点当前标签,都是 no-op(P0-3)
|
|
811
|
+
target.openSession(id);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const close = (id) => {
|
|
815
|
+
const at = ids.indexOf(id);
|
|
816
|
+
if (at < 0) return;
|
|
817
|
+
const landing = closeTarget(ids, at);
|
|
818
|
+
const wasActive = id === activeId;
|
|
819
|
+
deps.sync.close(id); // 乐观移除 + 同步到宿主:其它设备上的同名标签也会消失
|
|
820
|
+
const target = deps.workspace.current;
|
|
821
|
+
if (!wasActive || !target) return;
|
|
822
|
+
if (landing !== undefined) {
|
|
823
|
+
target.openSession(landing);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
// 最后一个标签:`clearMain` 不是公开接缝,所以只移除标签,主视图不动(见 lib/client.d.ts)。
|
|
827
|
+
warnOnce('clearMain', '最后一个标签已关闭:主视图保持不动(clearMain 不是公开接缝)。');
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// 抽屉的三个动作:切换、关标签、新建。每个都在做完动作后收掉抽屉——动作已经完成还挡着面板没有意义。
|
|
831
|
+
const closeSheet = () => setSheetOpen(false);
|
|
832
|
+
const pick = (id) => {
|
|
833
|
+
activate(id);
|
|
834
|
+
closeSheet();
|
|
835
|
+
};
|
|
836
|
+
const closeFromSheet = (id) => {
|
|
837
|
+
close(id);
|
|
838
|
+
if (ids.length <= 1) closeSheet(); // 关的是最后一个标签:抽屉里什么都不剩,直接收
|
|
839
|
+
};
|
|
840
|
+
// Esc 收抽屉;Tab/Shift+Tab 在面板内环绕。`aria-modal` 只是声明,真正的模态行为得自己维持,
|
|
841
|
+
// 否则焦点会跑到底下被遮住的宿主 UI 上去。没有 `document`(离线替身)就不做环绕,只保留 Esc。
|
|
842
|
+
const onSheetKeyDown = (event) => {
|
|
843
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
844
|
+
event.preventDefault();
|
|
845
|
+
closeSheet();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (event.key !== 'Tab' || typeof document === 'undefined' || !sheetRef.current) return;
|
|
849
|
+
const node = sheetRef.current;
|
|
850
|
+
if (typeof node.querySelectorAll !== 'function') return;
|
|
851
|
+
const found = node.querySelectorAll('button,[role="button"]');
|
|
852
|
+
const list = found && found.length ? Array.prototype.slice.call(found) : [];
|
|
853
|
+
if (list.length === 0) return;
|
|
854
|
+
const first = list[0];
|
|
855
|
+
const last = list[list.length - 1];
|
|
856
|
+
const current = document.activeElement;
|
|
857
|
+
if (!event.shiftKey && current === last) {
|
|
858
|
+
event.preventDefault();
|
|
859
|
+
if (typeof first.focus === 'function') first.focus();
|
|
860
|
+
} else if (event.shiftKey && (current === first || current === node)) {
|
|
861
|
+
event.preventDefault();
|
|
862
|
+
if (typeof last.focus === 'function') last.focus();
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const onKeyDown = (event) => {
|
|
867
|
+
const at = ids.indexOf(rovingId);
|
|
868
|
+
const closeKey = event.key === 'Delete' || event.key === 'Backspace';
|
|
869
|
+
const activateKey = event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar';
|
|
870
|
+
if (closeKey || activateKey) {
|
|
871
|
+
if (at < 0) return;
|
|
872
|
+
event.preventDefault();
|
|
873
|
+
if (closeKey) close(ids[at]);
|
|
874
|
+
else activate(ids[at]);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (narrow) return; // 窄屏只有一条可见标签:方向键无处可去,放行给宿主,别在不存在的目标间搬焦点
|
|
878
|
+
const next = nextIndex(event.key, at, ids.length);
|
|
879
|
+
if (next < 0) return; // 不认识的键放行给宿主
|
|
880
|
+
event.preventDefault();
|
|
881
|
+
setFocusId(ids[next]);
|
|
882
|
+
const nodes = listRef.current ? listRef.current.querySelectorAll('[role="tab"]') : null;
|
|
883
|
+
if (nodes && nodes[next] && nodes[next].focus) nodes[next].focus();
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// P1-2:竖向滚轮 → 横向滚动。只改 `scrollLeft`、不拦默认行为:外框是 `overflow:hidden`,
|
|
887
|
+
// 竖向滚轮在横条上没有可滚的祖先,默认动作本来就是空(取舍与备选写法见 README §7.6)。
|
|
888
|
+
const onWheel = (event) => {
|
|
889
|
+
const target = wheelTarget(listRef.current, event);
|
|
890
|
+
if (target !== undefined) listRef.current.scrollLeft = target;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
// 全部 hook 调用完毕之后才允许早退,否则 hook 次序会被破坏。
|
|
894
|
+
// 读不到会话清单 = 这个插件的前提没了:整体 no-op,而不是留一条空横条占着地方。
|
|
895
|
+
if (typeof props.useSessions !== 'function') return null;
|
|
896
|
+
if (anchor === 'failed') return null;
|
|
897
|
+
|
|
898
|
+
// 一条标签要显示的三件事(标题 / 状态 / 归属)在横条和抽屉行里是同一套,抽出来只算一遍。
|
|
899
|
+
const describe = (id) => {
|
|
900
|
+
const row = byId[id];
|
|
901
|
+
// 只有**真的拿到这条会话的状态**才画点;没有状态就不猜(P0-6)。
|
|
902
|
+
const status = statuses && statuses.get ? statuses.get(id) : undefined;
|
|
903
|
+
const ws = wsOf(id);
|
|
904
|
+
// 兜底归属没有 title/path:徽章宁可不画,也不编一个出来。
|
|
905
|
+
const item = ws ? wsItems.find((candidate) => candidate && candidate.workspaceId === ws) : undefined;
|
|
906
|
+
return {
|
|
907
|
+
row,
|
|
908
|
+
label: row ? row.displayTitle || row.title || id : id,
|
|
909
|
+
kind: status ? statusKind(status) : undefined,
|
|
910
|
+
ws,
|
|
911
|
+
item,
|
|
912
|
+
badge: item ? wsLabel(item) : undefined,
|
|
913
|
+
};
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const tabs = ids.map((id, at) => {
|
|
917
|
+
const info = describe(id);
|
|
918
|
+
const { row, label, kind, ws, item } = info;
|
|
919
|
+
// 归属:`wsOf` 已合成「列表反查 > 宿主兜底」。连组 = 与**前一个**标签同 workspace。
|
|
920
|
+
const join = !!ws && at > 0 && wsOf(ids[at - 1]) === ws;
|
|
921
|
+
return h(
|
|
922
|
+
'button',
|
|
923
|
+
{
|
|
924
|
+
key: id,
|
|
925
|
+
type: 'button',
|
|
926
|
+
role: 'tab',
|
|
927
|
+
className: 'dshtabs-tab',
|
|
928
|
+
'aria-selected': id === activeId,
|
|
929
|
+
'aria-disabled': canNavigate ? undefined : 'true',
|
|
930
|
+
tabIndex: id === rovingId ? 0 : -1,
|
|
931
|
+
title: label,
|
|
932
|
+
'data-missing': row ? undefined : '',
|
|
933
|
+
'data-kind': kind,
|
|
934
|
+
'data-ws': ws,
|
|
935
|
+
'data-ws-join': join,
|
|
936
|
+
style: ws ? { '--dshtabs-ws': wsTint(wsItems.length, wsIndex.rank[ws]) } : undefined,
|
|
937
|
+
onClick: () => activate(id),
|
|
938
|
+
onAuxClick: (event) => {
|
|
939
|
+
if (event.button !== 1) return; // 只认中键
|
|
940
|
+
event.preventDefault();
|
|
941
|
+
close(id);
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
// 色条在前、状态点在后,两者各占一格:色条 = 属于哪个 workspace,点 = 会话状态,互不重叠。
|
|
945
|
+
// 没有归属就没有色条(不给占位元素,标签自然窄一点,语义也不假)。
|
|
946
|
+
ws ? h('span', { key: 'bar', className: 'dshtabs-wsbar', 'aria-hidden': 'true' }) : null,
|
|
947
|
+
kind
|
|
948
|
+
? h('span', { key: 'dot', className: 'dshtabs-dot', 'data-kind': kind, title: STATUS_TEXT[kind], 'aria-hidden': 'true' })
|
|
949
|
+
: null,
|
|
950
|
+
h('span', { key: 'label', className: 'dshtabs-label' }, label),
|
|
951
|
+
info.badge ? h('span', { key: 'ws', className: 'dshtabs-ws', title: item.path, 'aria-hidden': 'true' }, info.badge) : null,
|
|
952
|
+
h(
|
|
953
|
+
'span',
|
|
954
|
+
{
|
|
955
|
+
key: 'close',
|
|
956
|
+
className: 'dshtabs-close',
|
|
957
|
+
'aria-hidden': 'true',
|
|
958
|
+
title: '关闭标签(会话保留)',
|
|
959
|
+
onClick: (event) => {
|
|
960
|
+
event.stopPropagation();
|
|
961
|
+
close(id);
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
'×',
|
|
965
|
+
),
|
|
966
|
+
);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// 当前主区会话**不在**标签集合里时(典型:别的设备把它关掉了),窄屏会连一个标题都没有——
|
|
970
|
+
// 横条只剩 ≡N 和 +,看起来像坏了。补一个只读的「当前会话」标签:不进 tablist、不可点、没有 ×,
|
|
971
|
+
// 因为它本来就不在集合里,能做的只有「告诉你现在在哪个会话」。宽屏不显示(display:none)。
|
|
972
|
+
const stray = activeId && ids.indexOf(activeId) < 0 ? describe(activeId) : undefined;
|
|
973
|
+
const strayStyle = stray && stray.ws ? { '--dshtabs-ws': wsTint(wsItems.length, wsIndex.rank[stray.ws]) } : undefined;
|
|
974
|
+
const strayNode = stray
|
|
975
|
+
? h(
|
|
976
|
+
'span',
|
|
977
|
+
{ key: 'stray', className: 'dshtabs-stray', 'data-kind': stray.kind, style: strayStyle },
|
|
978
|
+
stray.ws ? h('span', { key: 'bar', className: 'dshtabs-wsbar', 'aria-hidden': 'true' }) : null,
|
|
979
|
+
stray.kind ? h('span', { key: 'dot', className: 'dshtabs-dot', 'data-kind': stray.kind, 'aria-hidden': 'true' }) : null,
|
|
980
|
+
h('span', { key: 'label', className: 'dshtabs-label' }, stray.label),
|
|
981
|
+
)
|
|
982
|
+
: null;
|
|
983
|
+
|
|
984
|
+
// 抽屉按钮上的聚合状态点:一眼看出「有没有事在等我」,替代掉窄屏下看不见的那一排状态点。
|
|
985
|
+
const attention = attentionKind(statuses, ids);
|
|
986
|
+
|
|
987
|
+
// 抽屉行:全宽、一行一个。标题最多两行,第二行给**状态文字**——触屏没有悬停,
|
|
988
|
+
// 状态必须写出来,不能像桌面那样藏在 `title` 里。
|
|
989
|
+
const sheetRows = ids.map((id) => {
|
|
990
|
+
const info = describe(id);
|
|
991
|
+
const meta = [info.kind ? STATUS_TEXT[info.kind] : undefined, info.badge].filter(Boolean).join(' · ');
|
|
992
|
+
return h(
|
|
993
|
+
'div',
|
|
994
|
+
{
|
|
995
|
+
key: id,
|
|
996
|
+
className: 'dshtabs-row',
|
|
997
|
+
'aria-current': id === activeId ? 'true' : undefined,
|
|
998
|
+
'data-ws': info.ws,
|
|
999
|
+
style: info.ws ? { '--dshtabs-ws': wsTint(wsItems.length, wsIndex.rank[info.ws]) } : undefined,
|
|
1000
|
+
},
|
|
1001
|
+
h(
|
|
1002
|
+
'button',
|
|
1003
|
+
{
|
|
1004
|
+
key: 'main',
|
|
1005
|
+
type: 'button',
|
|
1006
|
+
className: 'dshtabs-row-main',
|
|
1007
|
+
'aria-disabled': canNavigate ? undefined : 'true',
|
|
1008
|
+
onClick: () => pick(id),
|
|
1009
|
+
},
|
|
1010
|
+
info.ws ? h('span', { key: 'bar', className: 'dshtabs-row-bar', 'aria-hidden': 'true' }) : null,
|
|
1011
|
+
info.kind ? h('span', { key: 'dot', className: 'dshtabs-dot', 'data-kind': info.kind, 'aria-hidden': 'true' }) : null,
|
|
1012
|
+
h(
|
|
1013
|
+
'span',
|
|
1014
|
+
{ key: 'text', className: 'dshtabs-row-text' },
|
|
1015
|
+
h('span', { key: 'title', className: 'dshtabs-row-title' }, info.label),
|
|
1016
|
+
meta ? h('span', { key: 'meta', className: 'dshtabs-row-meta' }, meta) : null,
|
|
1017
|
+
),
|
|
1018
|
+
),
|
|
1019
|
+
h(
|
|
1020
|
+
'button',
|
|
1021
|
+
{
|
|
1022
|
+
key: 'close',
|
|
1023
|
+
type: 'button',
|
|
1024
|
+
className: 'dshtabs-row-close',
|
|
1025
|
+
title: '关闭标签(会话保留)',
|
|
1026
|
+
'aria-label': '关闭标签(会话保留)',
|
|
1027
|
+
onClick: (event) => {
|
|
1028
|
+
if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
1029
|
+
closeFromSheet(id);
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
'×',
|
|
1033
|
+
),
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
const menu = h(
|
|
1038
|
+
'button',
|
|
1039
|
+
{
|
|
1040
|
+
key: 'menu',
|
|
1041
|
+
type: 'button',
|
|
1042
|
+
className: 'dshtabs-menu',
|
|
1043
|
+
ref: menuRef,
|
|
1044
|
+
title: '打开的会话(' + ids.length + ')',
|
|
1045
|
+
'aria-label': '打开的会话(' + ids.length + ')',
|
|
1046
|
+
'aria-haspopup': 'dialog',
|
|
1047
|
+
'aria-expanded': sheetOpen ? 'true' : 'false',
|
|
1048
|
+
onClick: () => setSheetOpen((open) => !open),
|
|
1049
|
+
},
|
|
1050
|
+
attention
|
|
1051
|
+
? h('span', { key: 'agg', className: 'dshtabs-dot', 'data-kind': attention, 'aria-hidden': 'true' })
|
|
1052
|
+
: null,
|
|
1053
|
+
h('span', { key: 'count', className: 'dshtabs-menu-count', 'aria-hidden': 'true' }, '≡ ' + ids.length),
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
// 抽屉只在打开时进 DOM:不打开就没有遮罩、没有焦点陷阱的问题,也没有多出来的节点。
|
|
1057
|
+
const sheet = sheetOpen
|
|
1058
|
+
? h(
|
|
1059
|
+
'div',
|
|
1060
|
+
{ key: 'sheet-root', className: 'dshtabs-sheet-root' },
|
|
1061
|
+
h('div', { key: 'scrim', className: 'dshtabs-scrim', 'aria-hidden': 'true', onClick: closeSheet }),
|
|
1062
|
+
h(
|
|
1063
|
+
'div',
|
|
1064
|
+
{
|
|
1065
|
+
key: 'panel',
|
|
1066
|
+
className: 'dshtabs-sheet',
|
|
1067
|
+
ref: sheetRef,
|
|
1068
|
+
role: 'dialog',
|
|
1069
|
+
'aria-modal': 'true',
|
|
1070
|
+
'aria-label': '打开的会话',
|
|
1071
|
+
tabIndex: -1,
|
|
1072
|
+
onKeyDown: onSheetKeyDown,
|
|
1073
|
+
},
|
|
1074
|
+
h(
|
|
1075
|
+
'div',
|
|
1076
|
+
{ key: 'head', className: 'dshtabs-sheet-head' },
|
|
1077
|
+
h('span', { key: 'title', className: 'dshtabs-sheet-title' }, '打开的会话'),
|
|
1078
|
+
h('span', { key: 'count' }, ids.length + ' 个 · 点击切换'),
|
|
1079
|
+
h(
|
|
1080
|
+
'button',
|
|
1081
|
+
{
|
|
1082
|
+
key: 'close',
|
|
1083
|
+
type: 'button',
|
|
1084
|
+
className: 'dshtabs-sheet-close',
|
|
1085
|
+
title: '收起',
|
|
1086
|
+
'aria-label': '收起',
|
|
1087
|
+
onClick: closeSheet,
|
|
1088
|
+
},
|
|
1089
|
+
'✕',
|
|
1090
|
+
),
|
|
1091
|
+
),
|
|
1092
|
+
h('div', { key: 'rows', className: 'dshtabs-sheet-rows' }, sheetRows),
|
|
1093
|
+
),
|
|
1094
|
+
)
|
|
1095
|
+
: null;
|
|
1096
|
+
|
|
1097
|
+
return h(
|
|
1098
|
+
'div',
|
|
1099
|
+
{
|
|
1100
|
+
ref: barRef,
|
|
1101
|
+
className: 'dshtabs',
|
|
1102
|
+
'data-dsh-tabs-bar': '',
|
|
1103
|
+
style: anchor === 'ok' ? undefined : { visibility: 'hidden' }, // 探测期隐藏:不重叠、不吃点击
|
|
1104
|
+
},
|
|
1105
|
+
[
|
|
1106
|
+
h('style', { key: 'css' }, CSS),
|
|
1107
|
+
// 抽屉入口放**左边**(P2-5):手机单手握持时左下角比右下角好按,也和「左侧=导航/菜单」的习惯一致。
|
|
1108
|
+
// 宽屏下它仍是 `display:none`,挪位置对桌面端没有任何影响。
|
|
1109
|
+
ids.length > 0 ? menu : null, // 一个标签都没有时不画入口:没有内容的抽屉没有意义
|
|
1110
|
+
h(
|
|
1111
|
+
'div',
|
|
1112
|
+
{
|
|
1113
|
+
key: 'list',
|
|
1114
|
+
ref: listRef,
|
|
1115
|
+
className: 'dshtabs-list',
|
|
1116
|
+
role: 'tablist',
|
|
1117
|
+
'aria-label': '打开过的会话(跨设备共享)',
|
|
1118
|
+
onKeyDown,
|
|
1119
|
+
onWheel,
|
|
1120
|
+
},
|
|
1121
|
+
tabs,
|
|
1122
|
+
),
|
|
1123
|
+
strayNode,
|
|
1124
|
+
sheet,
|
|
1125
|
+
],
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ── 插件体 ──
|
|
1130
|
+
|
|
1131
|
+
return {
|
|
1132
|
+
inject: ['slots'],
|
|
1133
|
+
apply(ctx) {
|
|
1134
|
+
// **服务可用性是异步的**:本插件 apply 的这一刻别的客户端插件往往还没 provide,
|
|
1135
|
+
// `ctx.get(name)` 会老实返回 undefined(strict 默认只看 active 的提供者)。三类接缝三种接法:
|
|
1136
|
+
// · `slots` 硬依赖 → 写进 inject,apply 只在它到位后才跑;
|
|
1137
|
+
// · `uiWorkspace` 软依赖 → `ctx.inject` 等它出现,出现/消失各自重跑回调;
|
|
1138
|
+
// · `sessions` / `uiSession` 不从这里读 —— 由槽位 standardProps 送进组件,见 TabBar。
|
|
1139
|
+
const workspace = { current: undefined, listeners: new Set() };
|
|
1140
|
+
const publish = () => {
|
|
1141
|
+
for (const listener of workspace.listeners) listener();
|
|
1142
|
+
};
|
|
1143
|
+
ctx.inject(['uiWorkspace'], (scoped) => {
|
|
1144
|
+
workspace.current = scoped.uiWorkspace;
|
|
1145
|
+
publish();
|
|
1146
|
+
return () => {
|
|
1147
|
+
workspace.current = undefined;
|
|
1148
|
+
publish();
|
|
1149
|
+
};
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
const warnOnce = once();
|
|
1153
|
+
const local = safeStorage('localStorage');
|
|
1154
|
+
if (!local) warn('Web Storage 不可用:标签只在本次页面内有效,刷新即丢。');
|
|
1155
|
+
|
|
1156
|
+
const fetchImpl = typeof window.fetch === 'function' ? window.fetch.bind(window) : undefined;
|
|
1157
|
+
const sync = createSync({ local, fetch: fetchImpl, warnOnce });
|
|
1158
|
+
// 同步的启动/停止跟插件的 fiber 走:停用插件即停止轮询与 focus 监听(P0-12 零残留)。
|
|
1159
|
+
if (typeof ctx.effect === 'function') {
|
|
1160
|
+
ctx.effect(
|
|
1161
|
+
() => {
|
|
1162
|
+
sync.start();
|
|
1163
|
+
return () => sync.stop();
|
|
1164
|
+
},
|
|
1165
|
+
'dsh-tabs: 共享集合',
|
|
1166
|
+
);
|
|
1167
|
+
} else {
|
|
1168
|
+
sync.start();
|
|
1169
|
+
warnOnce('effect', 'ctx.effect 缺失:同步客户端没有生命周期归属,停用插件后轮询不会停。');
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const deps = { workspace, warnOnce, sync };
|
|
1173
|
+
const Bound = (props) => h(TabBar, Object.assign({}, props, { __tabs: deps }));
|
|
1174
|
+
|
|
1175
|
+
// 注册归插件的 fiber 所有:卸载时这个 inject 的 disposer 会把格子摘掉。
|
|
1176
|
+
ctx.slots.inject(SLOT, () => ctx.slots.register({ name: SLOT, id: CELL_ID, order: -1 }, Bound));
|
|
1177
|
+
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
},
|
|
1181
|
+
});
|