dsh-tab 0.0.0-stage → 0.2.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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/client.js +1284 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +7 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/client.js
ADDED
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tab —— dsh Web GUI 的浏览器式会话标签页(浏览器半)。
|
|
3
|
+
*
|
|
4
|
+
* ## 层级
|
|
5
|
+
* 标签栏是会话区的顶层:位于「会话列 + 右侧产物列」之上,下面才是会话头部、
|
|
6
|
+
* 对话内容和右侧产物栏;切换标签页 = 切换整页会话(右侧产物栏跟随会话)。
|
|
7
|
+
*
|
|
8
|
+
* ## 外观
|
|
9
|
+
* 书页标签:标签栏是一条比页面底色浅一号的「书脊」,选中的标签抬到页面底色、
|
|
10
|
+
* 左右描边、不画底部横线,直接和下面的会话页连成一张纸。标签宽度跟着标题文字
|
|
11
|
+
* 自适应(不留空白),普通标签最多 180px,激活标签最多 270px(1.5 倍),
|
|
12
|
+
* 标题长才展开到上限。固定标签只显示图标。
|
|
13
|
+
*
|
|
14
|
+
* ## 右键菜单
|
|
15
|
+
* 关闭标签页 / 关闭右侧标签页 / 关闭其他标签页 / 关闭全部标签页 /
|
|
16
|
+
* 固定(取消固定)标签页 / 归档会话。归档有任务在跑的会话时先询问,确认后走
|
|
17
|
+
* 官方的「停止并归档」路径。
|
|
18
|
+
*
|
|
19
|
+
* ## 座位与机制(全部官方公开 API)
|
|
20
|
+
* - 标签栏 = `shell.overlay` 座位(官方全局浮层):标签栏绝对定位在会话区顶部,
|
|
21
|
+
* 横跨「会话列 + 右侧产物列」;帧布局样式把三栏整体下移 44px 让出这一行,
|
|
22
|
+
* 左栏(sidebar)保持整高不动。
|
|
23
|
+
* - 切换会话 = `ctx.uiWorkspace.openSession(id)`(官方侧栏行同一入口);
|
|
24
|
+
* - 新建对话 = `ctx.uiWorkspace.startSession()`(官方「新会话」入口,复用空白会话);
|
|
25
|
+
* - 归档会话 = `ctx.uiWorkspace.archiveSession(id, { stopActivity })`(官方归档入口);
|
|
26
|
+
* - 当前会话 = 官方会话目录里 `retainedBy.mainView > 0` 的那一条(官方 DocumentTitle 同款读法);
|
|
27
|
+
* - 运行/等待/未读 = 官方 `useSessionStatus` 投影;
|
|
28
|
+
* - 文字 = `ctx.locale.register` + 座位 `locale` 选项注入的 `t`。
|
|
29
|
+
*
|
|
30
|
+
* 最右侧的产物侧栏开关沿用官方会话头部角上的按钮(位于标签栏下一行最右)。
|
|
31
|
+
*
|
|
32
|
+
* 标签页是视图层记录(浏览器本地 localStorage 持久化):点击标签页切换当前会话,
|
|
33
|
+
* 不并行渲染多个会话(官方 Conversation 一页一条会话)。
|
|
34
|
+
*/
|
|
35
|
+
window.__ModuleLoader__.load({
|
|
36
|
+
id: 'dsh-tab',
|
|
37
|
+
factory(require) {
|
|
38
|
+
const React = require('react')
|
|
39
|
+
const h = React.createElement
|
|
40
|
+
const { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } = React
|
|
41
|
+
|
|
42
|
+
const NS = 'dsh-tab'
|
|
43
|
+
// 标签宽度/高度过渡约 140ms;等它跑完再切会话,避免和官方整页重渲染抢主线程
|
|
44
|
+
const SELECT_COMMIT_DELAY = 150
|
|
45
|
+
const STORAGE_KEY = 'dsh.tab.view.v2'
|
|
46
|
+
const LEGACY_STORAGE_KEY = 'dsh.tab.openSessions.v1'
|
|
47
|
+
|
|
48
|
+
// ── 文案(官方 locale 命名空间;en 为必填回退) ──────────────────────────
|
|
49
|
+
const ZH = {
|
|
50
|
+
'tab.list': '会话标签页',
|
|
51
|
+
'tab.new': '新建会话',
|
|
52
|
+
'tab.newSession': '新会话',
|
|
53
|
+
'tab.untitled': '未命名',
|
|
54
|
+
'tab.close': '关闭标签页:{title}',
|
|
55
|
+
'tab.running': '运行中',
|
|
56
|
+
'tab.pending': '等待回复',
|
|
57
|
+
'tab.unread': '有新完成内容',
|
|
58
|
+
'tab.pinned': '已固定',
|
|
59
|
+
'tab.workspace': '工作区:{name}',
|
|
60
|
+
'menu.close': '关闭标签页',
|
|
61
|
+
'menu.closeRight': '关闭右侧标签页',
|
|
62
|
+
'menu.closeOthers': '关闭其他标签页',
|
|
63
|
+
'menu.closeAll': '关闭全部标签页',
|
|
64
|
+
'menu.pin': '固定标签页',
|
|
65
|
+
'menu.unpin': '取消固定标签页',
|
|
66
|
+
'menu.archive': '归档会话',
|
|
67
|
+
'menu.archiveRunning': '这个会话还有任务在运行。归档会先停止它,再归档。',
|
|
68
|
+
'menu.archiveStop': '停止并归档',
|
|
69
|
+
'menu.archiveBusy': '正在归档…',
|
|
70
|
+
'menu.archiveFailed': '归档失败:{message}',
|
|
71
|
+
'menu.retryStop': '停止并重试',
|
|
72
|
+
'menu.cancel': '取消',
|
|
73
|
+
}
|
|
74
|
+
const EN = {
|
|
75
|
+
'tab.list': 'Conversation tabs',
|
|
76
|
+
'tab.new': 'New conversation',
|
|
77
|
+
'tab.newSession': 'New session',
|
|
78
|
+
'tab.untitled': 'Untitled',
|
|
79
|
+
'tab.close': 'Close tab: {title}',
|
|
80
|
+
'tab.running': 'Running',
|
|
81
|
+
'tab.pending': 'Waiting for you',
|
|
82
|
+
'tab.unread': 'New completed response',
|
|
83
|
+
'tab.pinned': 'Pinned',
|
|
84
|
+
'tab.workspace': 'Workspace: {name}',
|
|
85
|
+
'menu.close': 'Close tab',
|
|
86
|
+
'menu.closeRight': 'Close tabs to the right',
|
|
87
|
+
'menu.closeOthers': 'Close other tabs',
|
|
88
|
+
'menu.closeAll': 'Close all tabs',
|
|
89
|
+
'menu.pin': 'Pin tab',
|
|
90
|
+
'menu.unpin': 'Unpin tab',
|
|
91
|
+
'menu.archive': 'Archive conversation',
|
|
92
|
+
'menu.archiveRunning': 'This conversation still has work running. Archiving stops it first.',
|
|
93
|
+
'menu.archiveStop': 'Stop and archive',
|
|
94
|
+
'menu.archiveBusy': 'Archiving…',
|
|
95
|
+
'menu.archiveFailed': 'Archive failed: {message}',
|
|
96
|
+
'menu.retryStop': 'Stop and retry',
|
|
97
|
+
'menu.cancel': 'Cancel',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 帧布局(把 shell.bottom 那行搬到三栏上方,横跨会话列 + 右栏列) ─────
|
|
101
|
+
// 只依赖官方既有标记:帧上的 data-shell-bottom / data-shell-overlay,
|
|
102
|
+
// 拖拽把手上的 data-side。官方改这些标记时本段失效,标签栏会回落到三栏下方
|
|
103
|
+
// (不会崩溃),此时需要按新版 DOM 更新选择器。
|
|
104
|
+
const FRAME_CSS = `
|
|
105
|
+
/* 帧改成三行:第 1 行标签栏(44px 空行,标签栏由 shell.overlay 座位绝对定位填入)、
|
|
106
|
+
第 2 行会话内容、第 3 行官方 shell.bottom(诊断面板等)。 */
|
|
107
|
+
div:has(> [data-shell-bottom]):has(> [data-shell-overlay]) {
|
|
108
|
+
grid-template-rows: 44px minmax(0, 1fr) auto !important;
|
|
109
|
+
}
|
|
110
|
+
/* 右侧产物栏全屏时标签栏隐藏,首行归零,否则全屏面板上方留一条 44px 死区 */
|
|
111
|
+
div:has(> [data-shell-bottom])[data-rightbar-fullscreen]:has(> [data-shell-overlay]) {
|
|
112
|
+
grid-template-rows: 0 minmax(0, 1fr) auto !important;
|
|
113
|
+
}
|
|
114
|
+
/* 左栏贯穿第 1、2 行,保持整高 */
|
|
115
|
+
div:has(> [data-shell-bottom]):has(> [data-shell-overlay]) > :nth-child(1) {
|
|
116
|
+
grid-row: 1 / 3 !important;
|
|
117
|
+
grid-column: 1 !important;
|
|
118
|
+
}
|
|
119
|
+
/* 会话列、右栏列下移到第 2 行 */
|
|
120
|
+
div:has(> [data-shell-bottom]):has(> [data-shell-overlay]) > :nth-child(2) {
|
|
121
|
+
grid-row: 2 !important;
|
|
122
|
+
grid-column: 2 !important;
|
|
123
|
+
}
|
|
124
|
+
div:has(> [data-shell-bottom]):has(> [data-shell-overlay]) > :nth-child(3) {
|
|
125
|
+
grid-row: 2 !important;
|
|
126
|
+
grid-column: 3 !important;
|
|
127
|
+
}
|
|
128
|
+
/* 官方 shell.bottom 座位回到第 3 行(整宽) */
|
|
129
|
+
[data-shell-bottom] {
|
|
130
|
+
grid-row: 3 !important;
|
|
131
|
+
grid-column: 1 / -1 !important;
|
|
132
|
+
}
|
|
133
|
+
/* 列宽拖拽把手恢复成会话内容整高(第 1、2 行,不盖到第 3 行) */
|
|
134
|
+
div:has(> [data-shell-bottom]):has(> [data-shell-overlay]) > [data-side] {
|
|
135
|
+
grid-area: 1 / 1 / 3 / -1 !important;
|
|
136
|
+
}
|
|
137
|
+
/* Windows 标题栏:圆角原本在会话列左上,现在改由标签栏承载 */
|
|
138
|
+
[data-windows-titlebar] div:has(> [data-shell-bottom]) > :nth-child(2) {
|
|
139
|
+
border-top-left-radius: 0 !important;
|
|
140
|
+
}
|
|
141
|
+
`
|
|
142
|
+
|
|
143
|
+
// ── 标签栏外观(书页标签 + 右键菜单) ──────────────────────────────────
|
|
144
|
+
const CSS = `
|
|
145
|
+
/* 书脊条:比页面底色浅一号,底部一条细线 */
|
|
146
|
+
.dsh-tab-pagebar{position:absolute;top:0;right:0;z-index:2;pointer-events:auto;box-sizing:border-box;
|
|
147
|
+
left:max(var(--dsh-tab-left, 0px), var(--dsh-frame-leading-clearance, 0px));
|
|
148
|
+
display:flex;align-items:center;height:44px;padding:0 16px 0 20px;
|
|
149
|
+
background:var(--dsw-alias-bg-module-platform);
|
|
150
|
+
box-shadow:inset 0 -.5px 0 var(--dsw-alias-border-l2);
|
|
151
|
+
-webkit-app-region:drag;
|
|
152
|
+
--dsh-tab-max-width:180px}
|
|
153
|
+
html[data-windows-titlebar] .dsh-tab-pagebar{top:var(--dsh-windows-titlebar-height, 0px);
|
|
154
|
+
border-top-left-radius:var(--dsh-windows-content-radius, 16px);corner-shape:round}
|
|
155
|
+
[data-rightbar-fullscreen] .dsh-tab-pagebar{display:none}
|
|
156
|
+
.dsh-tab-pagebar__strip{display:flex;align-items:flex-end;flex:0 1 auto;min-width:0;
|
|
157
|
+
height:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;scroll-padding-inline:8px}
|
|
158
|
+
.dsh-tab-pagebar__strip::-webkit-scrollbar{display:none}
|
|
159
|
+
/* 短标签:书脊上的一张纸签 */
|
|
160
|
+
.dsh-tab-pagebar__tab{position:relative;box-sizing:border-box;display:flex;align-items:center;gap:7px;
|
|
161
|
+
flex:0 1 auto;min-width:72px;max-width:var(--dsh-tab-max-width);height:32px;padding:0 6px 0 12px;
|
|
162
|
+
border:.5px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;
|
|
163
|
+
background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:18px;
|
|
164
|
+
cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-app-region:no-drag;touch-action:pan-y;
|
|
165
|
+
animation:dsh-tab-enter .16s ease;
|
|
166
|
+
transition:background-color .12s ease,color .12s ease,height .14s cubic-bezier(.2,.7,.2,1),
|
|
167
|
+
max-width .14s cubic-bezier(.2,.7,.2,1),border-color .12s ease,border-radius .14s ease,box-shadow .12s ease}
|
|
168
|
+
.dsh-tab-pagebar__tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
|
|
169
|
+
/* 选中标签:抬到页面底色、不画底边线,和下面的会话页连成一张纸;
|
|
170
|
+
可用宽度是普通标签的 1.5 倍(180 → 270),标题短就按实际文字宽度收住、不留空白,
|
|
171
|
+
标题长才展开到 1.5 倍。切换时宽度和高度一起过渡
|
|
172
|
+
(只保留上方圆角,底部直角直接接页面,不再做额外的反圆角装饰) */
|
|
173
|
+
.dsh-tab-pagebar__tab[aria-selected=true]{max-width:calc(var(--dsh-tab-max-width) * 1.5);height:38px;
|
|
174
|
+
background:var(--dsw-alias-bg-base);
|
|
175
|
+
border-color:var(--dsw-alias-border-l2);border-radius:10px 10px 0 0;color:var(--dsw-alias-label-primary)}
|
|
176
|
+
.dsh-tab-pagebar__tab[aria-selected=true]:hover{background:var(--dsw-alias-bg-base);
|
|
177
|
+
color:var(--dsw-alias-label-primary)}
|
|
178
|
+
/* 固定标签:只剩图标、宽度固定 40px;激活时也不展开 */
|
|
179
|
+
.dsh-tab-pagebar__tab[data-pinned=true]{width:40px;min-width:40px;max-width:40px;flex:none;padding:0;
|
|
180
|
+
justify-content:center;gap:4px}
|
|
181
|
+
.dsh-tab-pagebar__tab[data-pinned=true] .dsh-tab-pagebar__label,
|
|
182
|
+
.dsh-tab-pagebar__tab[data-pinned=true] .dsh-tab-pagebar__close{display:none}
|
|
183
|
+
.dsh-tab-pagebar__divider{flex:none;align-self:center;width:1px;height:20px;margin:0 6px;
|
|
184
|
+
background:var(--dsw-alias-border-l2)}
|
|
185
|
+
.dsh-tab-pagebar__tab:focus-visible{outline:1.5px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
|
|
186
|
+
/* 拖拽排序:拖动中的标签变淡,落点画一条竖线 */
|
|
187
|
+
.dsh-tab-pagebar__tab[data-dragging=true]{opacity:.55;cursor:grabbing}
|
|
188
|
+
.dsh-tab-pagebar__tab[data-drop=before]{box-shadow:inset 2px 0 0 var(--dsw-alias-state-business-primary)}
|
|
189
|
+
.dsh-tab-pagebar__tab[data-drop=after]{box-shadow:inset -2px 0 0 var(--dsw-alias-state-business-primary)}
|
|
190
|
+
/* 左上角小角标:所属工作区(短名,悬停看全名) */
|
|
191
|
+
.dsh-tab-pagebar__badge{position:absolute;left:5px;top:-5px;box-sizing:border-box;max-width:50%;height:10px;
|
|
192
|
+
padding:0 3px;border-radius:3px;color:#fff;font-size:8.5px;font-weight:600;line-height:10px;letter-spacing:.01em;
|
|
193
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;opacity:.96}
|
|
194
|
+
/* 激活标签更高,角标随之内收,避免顶到窗口最上沿(两种状态在屏幕上几乎同一高度) */
|
|
195
|
+
.dsh-tab-pagebar__tab[aria-selected=true] .dsh-tab-pagebar__badge{top:1px}
|
|
196
|
+
.dsh-tab-pagebar__tab[data-pinned=true] .dsh-tab-pagebar__badge{left:2px;max-width:38px;padding:0 1px;font-size:7.5px}
|
|
197
|
+
.dsh-tab-pagebar__icon{position:relative;flex:none;width:16px;height:16px;display:grid;place-items:center;
|
|
198
|
+
color:var(--dsw-alias-label-tertiary)}
|
|
199
|
+
.dsh-tab-pagebar__tab[aria-selected=true] .dsh-tab-pagebar__icon{color:var(--dsw-alias-label-secondary)}
|
|
200
|
+
.dsh-tab-pagebar__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
201
|
+
.dsh-tab-pagebar__dot{flex:none;width:6px;height:6px;border-radius:50%}
|
|
202
|
+
.dsh-tab-pagebar__dot[data-signal=running]{background:var(--dsw-alias-state-business-primary);
|
|
203
|
+
animation:dsh-tab-pulse 1.6s ease-in-out infinite}
|
|
204
|
+
.dsh-tab-pagebar__dot[data-signal=pending]{background:var(--dsw-alias-state-warn-primary)}
|
|
205
|
+
.dsh-tab-pagebar__dot[data-signal=unread]{background:var(--dsw-alias-state-success-primary)}
|
|
206
|
+
.dsh-tab-pagebar__close{flex:none;width:18px;height:18px;display:grid;place-items:center;padding:0;
|
|
207
|
+
border:none;border-radius:5px;background:transparent;color:inherit;cursor:pointer;-webkit-app-region:no-drag;
|
|
208
|
+
opacity:0;pointer-events:none;transition:opacity .1s ease,background-color .1s ease}
|
|
209
|
+
.dsh-tab-pagebar__tab:hover .dsh-tab-pagebar__close,
|
|
210
|
+
.dsh-tab-pagebar__tab[aria-selected=true] .dsh-tab-pagebar__close,
|
|
211
|
+
.dsh-tab-pagebar__tab:focus-within .dsh-tab-pagebar__close{opacity:1;pointer-events:auto}
|
|
212
|
+
.dsh-tab-pagebar__close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
213
|
+
.dsh-tab-pagebar__close:focus-visible{outline:1.5px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
|
|
214
|
+
.dsh-tab-pagebar__new{flex:none;align-self:center;width:30px;height:30px;display:grid;place-items:center;padding:0;
|
|
215
|
+
margin-left:6px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;
|
|
216
|
+
color:var(--dsw-alias-label-secondary);cursor:pointer;-webkit-app-region:no-drag;
|
|
217
|
+
transition:background-color .12s ease,color .12s ease}
|
|
218
|
+
.dsh-tab-pagebar__new:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
219
|
+
.dsh-tab-pagebar__new:focus-visible{outline:1.5px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
|
|
220
|
+
/* 右键菜单 */
|
|
221
|
+
.dsh-tab-menu{position:fixed;z-index:100;box-sizing:border-box;min-width:188px;max-width:260px;padding:5px;
|
|
222
|
+
border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);
|
|
223
|
+
background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv2);
|
|
224
|
+
color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;
|
|
225
|
+
-webkit-app-region:no-drag;user-select:none;-webkit-user-select:none}
|
|
226
|
+
.dsh-tab-menu__item{display:flex;align-items:center;width:100%;height:30px;padding:0 8px;border:none;border-radius:6px;
|
|
227
|
+
background:transparent;color:inherit;font:inherit;text-align:left;white-space:nowrap;cursor:pointer}
|
|
228
|
+
.dsh-tab-menu__item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
229
|
+
.dsh-tab-menu__item:disabled{color:var(--dsw-alias-label-dimmed);cursor:default;background:transparent}
|
|
230
|
+
.dsh-tab-menu__item[data-danger=true]:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);
|
|
231
|
+
color:var(--dsw-alias-state-error-primary)}
|
|
232
|
+
.dsh-tab-menu__sep{height:.5px;margin:4px 6px;background:var(--dsw-alias-border-l2)}
|
|
233
|
+
.dsh-tab-menu__hint{padding:4px 8px 6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px;
|
|
234
|
+
white-space:normal}
|
|
235
|
+
@keyframes dsh-tab-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
236
|
+
@keyframes dsh-tab-enter{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
|
|
237
|
+
@media (prefers-reduced-motion:reduce){.dsh-tab-pagebar__dot[data-signal=running]{animation:none}}
|
|
238
|
+
@media (prefers-reduced-motion:reduce){
|
|
239
|
+
.dsh-tab-pagebar__tab,.dsh-tab-pagebar__new,.dsh-tab-pagebar__close{transition:none;animation:none}
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
|
|
243
|
+
// ── 图标(16px 线性图标,currentColor 跟随主题) ─────────────────────────
|
|
244
|
+
function svg(children) {
|
|
245
|
+
return h(
|
|
246
|
+
'svg',
|
|
247
|
+
{
|
|
248
|
+
viewBox: '0 0 16 16',
|
|
249
|
+
width: 16,
|
|
250
|
+
height: 16,
|
|
251
|
+
fill: 'none',
|
|
252
|
+
stroke: 'currentColor',
|
|
253
|
+
strokeWidth: 1.4,
|
|
254
|
+
strokeLinecap: 'round',
|
|
255
|
+
strokeLinejoin: 'round',
|
|
256
|
+
'aria-hidden': true,
|
|
257
|
+
focusable: false,
|
|
258
|
+
},
|
|
259
|
+
children,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
function IconConversation() {
|
|
263
|
+
return svg(
|
|
264
|
+
h('path', {
|
|
265
|
+
d: 'M13.2 8c0 2.6-2.3 4.7-5.2 4.7-.6 0-1.2-.1-1.8-.3L3.5 13.2l.7-2c-.9-.8-1.4-2-1.4-3.2C2.8 5.4 5.1 3.3 8 3.3s5.2 2.1 5.2 4.7Z',
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
function IconCompass() {
|
|
270
|
+
return svg([
|
|
271
|
+
h('circle', { key: 'c', cx: 8, cy: 8, r: 5.4 }),
|
|
272
|
+
h('path', { key: 'n', d: 'M9.9 6.1 8.7 8.7 6.1 9.9l1.2-2.6 2.6-1.2Z' }),
|
|
273
|
+
])
|
|
274
|
+
}
|
|
275
|
+
function IconPlus() {
|
|
276
|
+
return svg(h('path', { d: 'M8 3.2v9.6M3.2 8h9.6' }))
|
|
277
|
+
}
|
|
278
|
+
function IconClose() {
|
|
279
|
+
return h(
|
|
280
|
+
'svg',
|
|
281
|
+
{
|
|
282
|
+
viewBox: '0 0 16 16',
|
|
283
|
+
width: 12,
|
|
284
|
+
height: 12,
|
|
285
|
+
fill: 'none',
|
|
286
|
+
stroke: 'currentColor',
|
|
287
|
+
strokeWidth: 1.5,
|
|
288
|
+
strokeLinecap: 'round',
|
|
289
|
+
'aria-hidden': true,
|
|
290
|
+
focusable: false,
|
|
291
|
+
},
|
|
292
|
+
h('path', { d: 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6' }),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── 持久化(视图态,不进会话日志) ───────────────────────────────────────
|
|
297
|
+
function sanitizeIds(value) {
|
|
298
|
+
if (!Array.isArray(value)) return []
|
|
299
|
+
const seen = new Set()
|
|
300
|
+
const ids = []
|
|
301
|
+
for (const item of value) {
|
|
302
|
+
if (typeof item !== 'string' || item.length === 0 || seen.has(item)) continue
|
|
303
|
+
seen.add(item)
|
|
304
|
+
ids.push(item)
|
|
305
|
+
}
|
|
306
|
+
return ids
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function readStoredState() {
|
|
310
|
+
try {
|
|
311
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
312
|
+
if (raw !== null) {
|
|
313
|
+
const value = JSON.parse(raw)
|
|
314
|
+
if (value !== null && typeof value === 'object') {
|
|
315
|
+
const ids = sanitizeIds(value.tabs)
|
|
316
|
+
const pinned = sanitizeIds(value.pinned).filter((id) => ids.includes(id))
|
|
317
|
+
return { ids, pinned }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
|
|
321
|
+
if (legacy !== null) return { ids: sanitizeIds(JSON.parse(legacy)), pinned: [] }
|
|
322
|
+
} catch (error) {
|
|
323
|
+
/* 存储不可用 / 内容损坏:从空列表开始,标签页只是视图态 */
|
|
324
|
+
}
|
|
325
|
+
return { ids: [], pinned: [] }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createTabStore() {
|
|
329
|
+
const stored = readStoredState()
|
|
330
|
+
let ids = stored.ids
|
|
331
|
+
let pinned = stored.pinned
|
|
332
|
+
const suppressed = new Set()
|
|
333
|
+
let snapshot = Object.freeze({ ids: Object.freeze(ids.slice()), pinned: Object.freeze(pinned.slice()) })
|
|
334
|
+
const listeners = new Set()
|
|
335
|
+
const publish = () => {
|
|
336
|
+
snapshot = Object.freeze({ ids: Object.freeze(ids.slice()), pinned: Object.freeze(pinned.slice()) })
|
|
337
|
+
try {
|
|
338
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs: ids, pinned }))
|
|
339
|
+
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
/* 无痕模式 / 存储禁用:标签页只是视图态,存不进不影响会话数据 */
|
|
342
|
+
}
|
|
343
|
+
for (const listener of [...listeners]) listener()
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
subscribe(listener) {
|
|
347
|
+
listeners.add(listener)
|
|
348
|
+
return () => listeners.delete(listener)
|
|
349
|
+
},
|
|
350
|
+
getSnapshot() {
|
|
351
|
+
return snapshot
|
|
352
|
+
},
|
|
353
|
+
has(id) {
|
|
354
|
+
return ids.includes(id)
|
|
355
|
+
},
|
|
356
|
+
add(id) {
|
|
357
|
+
if (ids.includes(id)) return
|
|
358
|
+
ids = [...ids, id]
|
|
359
|
+
publish()
|
|
360
|
+
},
|
|
361
|
+
pin(id) {
|
|
362
|
+
if (!ids.includes(id) || pinned.includes(id)) return
|
|
363
|
+
pinned = [...pinned, id]
|
|
364
|
+
publish()
|
|
365
|
+
},
|
|
366
|
+
unpin(id) {
|
|
367
|
+
if (!pinned.includes(id)) return
|
|
368
|
+
pinned = pinned.filter((item) => item !== id)
|
|
369
|
+
publish()
|
|
370
|
+
},
|
|
371
|
+
removeMany(removed) {
|
|
372
|
+
const drop = new Set(removed)
|
|
373
|
+
if (drop.size === 0) return
|
|
374
|
+
ids = ids.filter((item) => !drop.has(item))
|
|
375
|
+
pinned = pinned.filter((item) => !drop.has(item))
|
|
376
|
+
publish()
|
|
377
|
+
},
|
|
378
|
+
/** 固定分组内拖动排序(referenceId 目标标签,place 表示放在它前/后)。 */
|
|
379
|
+
movePinned(id, referenceId, place) {
|
|
380
|
+
if (!pinned.includes(id) || !pinned.includes(referenceId) || id === referenceId) return
|
|
381
|
+
const next = pinned.filter((item) => item !== id)
|
|
382
|
+
const referenceIndex = next.indexOf(referenceId)
|
|
383
|
+
if (referenceIndex < 0) return
|
|
384
|
+
next.splice(place === 'after' ? referenceIndex + 1 : referenceIndex, 0, id)
|
|
385
|
+
pinned = next
|
|
386
|
+
publish()
|
|
387
|
+
},
|
|
388
|
+
/** 未固定分组内拖动排序(显示顺序就是 ids 顺序)。 */
|
|
389
|
+
moveUnpinned(id, referenceId, place) {
|
|
390
|
+
if (!ids.includes(id) || id === referenceId) return
|
|
391
|
+
const next = ids.filter((item) => item !== id)
|
|
392
|
+
const referenceIndex = next.indexOf(referenceId)
|
|
393
|
+
if (referenceIndex < 0) return
|
|
394
|
+
next.splice(place === 'after' ? referenceIndex + 1 : referenceIndex, 0, id)
|
|
395
|
+
ids = next
|
|
396
|
+
publish()
|
|
397
|
+
},
|
|
398
|
+
/** 主动关掉的标签不要再被「当前会话自动入签」立刻加回来。 */
|
|
399
|
+
suppress(id) {
|
|
400
|
+
suppressed.add(id)
|
|
401
|
+
},
|
|
402
|
+
isSuppressed(id) {
|
|
403
|
+
return suppressed.has(id)
|
|
404
|
+
},
|
|
405
|
+
unsuppress(id) {
|
|
406
|
+
suppressed.delete(id)
|
|
407
|
+
},
|
|
408
|
+
pruneSuppressed(keepId) {
|
|
409
|
+
for (const id of suppressed) if (id !== keepId) suppressed.delete(id)
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── 官方会话目录里读「当前会话」 ─────────────────────────────────────────
|
|
415
|
+
function currentSessionIdOf(catalog) {
|
|
416
|
+
if (catalog === undefined || catalog === null) return undefined
|
|
417
|
+
const rows = catalog.byId ?? {}
|
|
418
|
+
for (const id of catalog.ids ?? []) {
|
|
419
|
+
const row = rows[id]
|
|
420
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return id
|
|
421
|
+
}
|
|
422
|
+
return undefined
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function sessionStatusSignal(status) {
|
|
426
|
+
if (status === undefined || status === null) return undefined
|
|
427
|
+
if (status.pendingInteraction !== undefined && status.pendingInteraction !== null) return 'pending'
|
|
428
|
+
if (status.running === true) return 'running'
|
|
429
|
+
if (status.completionUnread === true) return 'unread'
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 路径最后一段(兼容 / 与 \\)。 */
|
|
434
|
+
function basenameOf(path) {
|
|
435
|
+
if (typeof path !== 'string') return ''
|
|
436
|
+
const parts = path.split(/[\\/]/).filter((part) => part.length > 0)
|
|
437
|
+
return parts.length === 0 ? '' : parts[parts.length - 1]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 工作区名 → 角标颜色(固定色相,同一工作区在任何主题下颜色一致)。 */
|
|
441
|
+
function workspaceHue(key) {
|
|
442
|
+
let hash = 0
|
|
443
|
+
for (let index = 0; index < key.length; index += 1) hash = (hash * 31 + key.charCodeAt(index)) % 360
|
|
444
|
+
return hash
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function errorMessageOf(error) {
|
|
448
|
+
if (error === undefined || error === null) return ''
|
|
449
|
+
if (typeof error === 'string') return error
|
|
450
|
+
if (typeof error.message === 'string' && error.message.length > 0) return error.message
|
|
451
|
+
return String(error)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 显示顺序:固定标签在最左(按固定顺序),其余按打开顺序。
|
|
456
|
+
* 切换标签不改变顺序;激活标签只是变宽、和下面的会话页连成一张纸。
|
|
457
|
+
*/
|
|
458
|
+
function displayTabs(snapshot) {
|
|
459
|
+
const pinnedSet = new Set(snapshot.pinned)
|
|
460
|
+
const pinnedTabs = snapshot.pinned
|
|
461
|
+
.filter((id) => snapshot.ids.includes(id))
|
|
462
|
+
.map((id) => ({ id, pinned: true }))
|
|
463
|
+
const rest = snapshot.ids.filter((id) => !pinnedSet.has(id)).map((id) => ({ id, pinned: false }))
|
|
464
|
+
return [...pinnedTabs, ...rest]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── 单个标签页 ──────────────────────────────────────────────────────────
|
|
468
|
+
function TabItem(props) {
|
|
469
|
+
const { id, title, active, signal, blank, pinned, badge, drop, dragging, t, onSelect, onClose, onMenu, onArrow, onDragStart } = props
|
|
470
|
+
const statusLabel =
|
|
471
|
+
signal === 'running' ? t('tab.running') : signal === 'pending' ? t('tab.pending') : signal === 'unread' ? t('tab.unread') : undefined
|
|
472
|
+
return h(
|
|
473
|
+
'div',
|
|
474
|
+
{
|
|
475
|
+
className: 'dsh-tab-pagebar__tab',
|
|
476
|
+
role: 'tab',
|
|
477
|
+
tabIndex: active ? 0 : -1,
|
|
478
|
+
'aria-selected': active,
|
|
479
|
+
'data-session-id': id,
|
|
480
|
+
'data-pinned': pinned ? 'true' : 'false',
|
|
481
|
+
'data-dragging': dragging ? 'true' : 'false',
|
|
482
|
+
'data-drop': drop === null || drop === undefined ? undefined : drop,
|
|
483
|
+
title: pinned ? `${title} · ${t('tab.pinned')}` : title,
|
|
484
|
+
onClick: () => onSelect(id),
|
|
485
|
+
onPointerDown: (event) => {
|
|
486
|
+
if (event.button !== 0) return
|
|
487
|
+
const target = event.target
|
|
488
|
+
if (target !== null && typeof target.closest === 'function' && target.closest('.dsh-tab-pagebar__close') !== null) return
|
|
489
|
+
onDragStart(event)
|
|
490
|
+
},
|
|
491
|
+
onContextMenu: (event) => onMenu(event, id),
|
|
492
|
+
onAuxClick: (event) => {
|
|
493
|
+
if (event.button === 1) {
|
|
494
|
+
event.preventDefault()
|
|
495
|
+
onClose(id)
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
onKeyDown: (event) => {
|
|
499
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
500
|
+
event.preventDefault()
|
|
501
|
+
onSelect(id)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
if (event.key === 'ArrowRight') {
|
|
505
|
+
event.preventDefault()
|
|
506
|
+
onArrow(id, 1)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
if (event.key === 'ArrowLeft') {
|
|
510
|
+
event.preventDefault()
|
|
511
|
+
onArrow(id, -1)
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
badge === null || badge === undefined
|
|
516
|
+
? null
|
|
517
|
+
: h(
|
|
518
|
+
'span',
|
|
519
|
+
{
|
|
520
|
+
key: 'badge',
|
|
521
|
+
className: 'dsh-tab-pagebar__badge',
|
|
522
|
+
style: { backgroundColor: `hsl(${badge.hue} 46% 38%)` },
|
|
523
|
+
title: t('tab.workspace', { name: badge.label }),
|
|
524
|
+
'aria-hidden': 'true',
|
|
525
|
+
},
|
|
526
|
+
badge.label,
|
|
527
|
+
),
|
|
528
|
+
h('span', { className: 'dsh-tab-pagebar__icon' }, blank ? h(IconCompass) : h(IconConversation)),
|
|
529
|
+
h('span', { className: 'dsh-tab-pagebar__label' }, title),
|
|
530
|
+
signal !== undefined
|
|
531
|
+
? h('span', {
|
|
532
|
+
key: 'dot',
|
|
533
|
+
className: 'dsh-tab-pagebar__dot',
|
|
534
|
+
'data-signal': signal,
|
|
535
|
+
role: 'img',
|
|
536
|
+
'aria-label': statusLabel,
|
|
537
|
+
title: statusLabel,
|
|
538
|
+
})
|
|
539
|
+
: null,
|
|
540
|
+
pinned
|
|
541
|
+
? null
|
|
542
|
+
: h(
|
|
543
|
+
'button',
|
|
544
|
+
{
|
|
545
|
+
type: 'button',
|
|
546
|
+
tabIndex: -1,
|
|
547
|
+
className: 'dsh-tab-pagebar__close',
|
|
548
|
+
title: t('tab.close', { title }),
|
|
549
|
+
'aria-label': t('tab.close', { title }),
|
|
550
|
+
onClick: (event) => {
|
|
551
|
+
event.stopPropagation()
|
|
552
|
+
onClose(id)
|
|
553
|
+
},
|
|
554
|
+
onContextMenu: (event) => {
|
|
555
|
+
event.preventDefault()
|
|
556
|
+
onMenu(event, id)
|
|
557
|
+
},
|
|
558
|
+
onKeyDown: (event) => {
|
|
559
|
+
event.stopPropagation()
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
h(IconClose),
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── 右键菜单 ────────────────────────────────────────────────────────────
|
|
568
|
+
function TabMenu(props) {
|
|
569
|
+
const { x, y, hint, items, onClose } = props
|
|
570
|
+
const menuRef = useRef(null)
|
|
571
|
+
const [position, setPosition] = useState({ x, y })
|
|
572
|
+
|
|
573
|
+
// 打开后按实际尺寸夹到窗口内,避免菜单跑出屏幕
|
|
574
|
+
useLayoutEffect(() => {
|
|
575
|
+
const element = menuRef.current
|
|
576
|
+
if (element === null) return
|
|
577
|
+
const rect = element.getBoundingClientRect()
|
|
578
|
+
const nextX = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8))
|
|
579
|
+
const nextY = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8))
|
|
580
|
+
if (nextX !== position.x || nextY !== position.y) setPosition({ x: nextX, y: nextY })
|
|
581
|
+
}, [x, y, hint, items.length])
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
const onPointerDown = (event) => {
|
|
585
|
+
if (menuRef.current !== null && menuRef.current.contains(event.target)) return
|
|
586
|
+
onClose()
|
|
587
|
+
}
|
|
588
|
+
const onKeyDown = (event) => {
|
|
589
|
+
if (event.key === 'Escape') {
|
|
590
|
+
event.preventDefault()
|
|
591
|
+
event.stopPropagation()
|
|
592
|
+
onClose()
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const onDismiss = () => onClose()
|
|
596
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
597
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
598
|
+
window.addEventListener('scroll', onDismiss, true)
|
|
599
|
+
window.addEventListener('resize', onDismiss)
|
|
600
|
+
return () => {
|
|
601
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
602
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
603
|
+
window.removeEventListener('scroll', onDismiss, true)
|
|
604
|
+
window.removeEventListener('resize', onDismiss)
|
|
605
|
+
}
|
|
606
|
+
}, [onClose])
|
|
607
|
+
|
|
608
|
+
return h(
|
|
609
|
+
'div',
|
|
610
|
+
{
|
|
611
|
+
ref: menuRef,
|
|
612
|
+
className: 'dsh-tab-menu',
|
|
613
|
+
role: 'menu',
|
|
614
|
+
style: { left: `${position.x}px`, top: `${position.y}px` },
|
|
615
|
+
onContextMenu: (event) => event.preventDefault(),
|
|
616
|
+
},
|
|
617
|
+
hint === undefined || hint === null ? null : h('div', { key: 'hint', className: 'dsh-tab-menu__hint' }, hint),
|
|
618
|
+
items.map((item) =>
|
|
619
|
+
item.separator === true
|
|
620
|
+
? h('div', { key: item.key, className: 'dsh-tab-menu__sep', role: 'separator' })
|
|
621
|
+
: h(
|
|
622
|
+
'button',
|
|
623
|
+
{
|
|
624
|
+
key: item.key,
|
|
625
|
+
type: 'button',
|
|
626
|
+
role: 'menuitem',
|
|
627
|
+
className: 'dsh-tab-menu__item',
|
|
628
|
+
disabled: item.disabled === true,
|
|
629
|
+
'data-danger': item.danger === true ? 'true' : 'false',
|
|
630
|
+
onClick: () => item.run(),
|
|
631
|
+
},
|
|
632
|
+
item.label,
|
|
633
|
+
),
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── 标签栏 ──────────────────────────────────────────────────────────────
|
|
639
|
+
function createTabBar(actions) {
|
|
640
|
+
return function TabBar(props) {
|
|
641
|
+
const { useSessions, useSessionStatus, useWorkspaces, t } = props
|
|
642
|
+
const catalog = useSessions((state) => state)
|
|
643
|
+
const statuses = useSessionStatus((state) => state)
|
|
644
|
+
const workspaces = useWorkspaces((state) => state)
|
|
645
|
+
const snapshot = useSyncExternalStore(actions.store.subscribe, actions.store.getSnapshot)
|
|
646
|
+
const [menu, setMenu] = useState(null)
|
|
647
|
+
|
|
648
|
+
const rows = catalog?.byId ?? {}
|
|
649
|
+
const archived = useMemo(() => new Set(workspaces?.archivedSessionIds ?? []), [workspaces])
|
|
650
|
+
const activeId = currentSessionIdOf(catalog)
|
|
651
|
+
|
|
652
|
+
// 会话 → 所属工作区(角标用)。工作区标题为空时用目录名,再退到 workspaceId。
|
|
653
|
+
const workspaceBadges = useMemo(() => {
|
|
654
|
+
const map = new Map()
|
|
655
|
+
for (const item of workspaces?.items ?? []) {
|
|
656
|
+
const title = typeof item.title === 'string' && item.title.trim().length > 0 ? item.title.trim() : ''
|
|
657
|
+
const label = title || basenameOf(item.path) || (item.workspaceId === undefined ? '' : String(item.workspaceId))
|
|
658
|
+
if (label.length === 0) continue
|
|
659
|
+
const hue = workspaceHue(item.workspaceId === undefined ? label : String(item.workspaceId))
|
|
660
|
+
for (const sessionId of item.sessionIds ?? []) if (!map.has(sessionId)) map.set(sessionId, { label, hue })
|
|
661
|
+
}
|
|
662
|
+
return map
|
|
663
|
+
}, [workspaces])
|
|
664
|
+
|
|
665
|
+
// 点标签时先把视觉上的「激活」切过去跑动画,等动画结束再真正切会话。
|
|
666
|
+
// 官方切会话会同步重渲染整个会话页(实测一次约 60–75ms 长任务),
|
|
667
|
+
// 和标签动画同时发生会直接把动画顶卡;拆开之后两段各自流畅。
|
|
668
|
+
const [pendingSelect, setPendingSelect] = useState(null)
|
|
669
|
+
const pendingRef = useRef(null)
|
|
670
|
+
const pendingTimerRef = useRef(null)
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
pendingRef.current = pendingSelect
|
|
673
|
+
}, [pendingSelect])
|
|
674
|
+
const clearPendingSelect = useCallback(() => {
|
|
675
|
+
if (pendingTimerRef.current !== null) {
|
|
676
|
+
clearTimeout(pendingTimerRef.current)
|
|
677
|
+
pendingTimerRef.current = null
|
|
678
|
+
}
|
|
679
|
+
setPendingSelect(null)
|
|
680
|
+
}, [])
|
|
681
|
+
|
|
682
|
+
// 当前会话自动入签(官方侧栏点开会话、启动恢复、新建会话都走这里)
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
actions.store.pruneSuppressed(activeId)
|
|
685
|
+
if (activeId === undefined) return
|
|
686
|
+
if (actions.store.isSuppressed(activeId)) return
|
|
687
|
+
if (rows[activeId] === undefined) return
|
|
688
|
+
if (!actions.store.has(activeId)) actions.store.add(activeId)
|
|
689
|
+
}, [activeId, catalog])
|
|
690
|
+
|
|
691
|
+
// 会话被删除 / 归档后摘签
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (catalog?.phase !== 'ready') return
|
|
694
|
+
const stale = actions.store.getSnapshot().ids.filter((id) => rows[id] === undefined || archived.has(id))
|
|
695
|
+
if (stale.length > 0) actions.store.removeMany(stale)
|
|
696
|
+
}, [catalog, archived])
|
|
697
|
+
|
|
698
|
+
const tabs = displayTabs(snapshot).filter((tab) => rows[tab.id] !== undefined && !archived.has(tab.id))
|
|
699
|
+
const pinnedCount = tabs.filter((tab) => tab.pinned).length
|
|
700
|
+
const visualActiveId = pendingSelect === null ? activeId : pendingSelect.id
|
|
701
|
+
const stripRef = useRef(null)
|
|
702
|
+
const barRef = useRef(null)
|
|
703
|
+
|
|
704
|
+
const requestSelect = useCallback(
|
|
705
|
+
(id) => {
|
|
706
|
+
if (id === visualActiveId) return
|
|
707
|
+
const reduceMotion =
|
|
708
|
+
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
709
|
+
if (reduceMotion) {
|
|
710
|
+
clearPendingSelect()
|
|
711
|
+
actions.select(id)
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
if (pendingTimerRef.current !== null) {
|
|
715
|
+
clearTimeout(pendingTimerRef.current)
|
|
716
|
+
pendingTimerRef.current = null
|
|
717
|
+
}
|
|
718
|
+
setPendingSelect({ id, fromId: activeId })
|
|
719
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
720
|
+
pendingTimerRef.current = null
|
|
721
|
+
const pending = pendingRef.current
|
|
722
|
+
if (pending === null || pending.id !== id) return
|
|
723
|
+
if (!actions.store.has(id)) {
|
|
724
|
+
setPendingSelect(null)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
actions.select(id)
|
|
728
|
+
setPendingSelect(null)
|
|
729
|
+
}, SELECT_COMMIT_DELAY)
|
|
730
|
+
},
|
|
731
|
+
[activeId, visualActiveId, actions, clearPendingSelect],
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
// ── 拖拽排序 ────────────────────────────────────────────────────────
|
|
735
|
+
const [dragState, setDragState] = useState(null)
|
|
736
|
+
const dragRef = useRef(null)
|
|
737
|
+
const dragCleanupRef = useRef(null)
|
|
738
|
+
const selectGuardRef = useRef(0)
|
|
739
|
+
useEffect(
|
|
740
|
+
() => () => {
|
|
741
|
+
if (dragCleanupRef.current !== null) dragCleanupRef.current()
|
|
742
|
+
},
|
|
743
|
+
[],
|
|
744
|
+
)
|
|
745
|
+
const onTabSelect = useCallback(
|
|
746
|
+
(id) => {
|
|
747
|
+
if (performance.now() < selectGuardRef.current) return
|
|
748
|
+
requestSelect(id)
|
|
749
|
+
},
|
|
750
|
+
[requestSelect],
|
|
751
|
+
)
|
|
752
|
+
const beginDrag = (event, id, pinned) => {
|
|
753
|
+
if (event.button !== 0 || dragCleanupRef.current !== null) return
|
|
754
|
+
const strip = stripRef.current
|
|
755
|
+
if (strip === null) return
|
|
756
|
+
const candidates = tabs
|
|
757
|
+
.filter((tab) => tab.pinned === pinned)
|
|
758
|
+
.map((tab) => {
|
|
759
|
+
const element = strip.querySelector(`[data-session-id="${tab.id}"]`)
|
|
760
|
+
if (element === null) return null
|
|
761
|
+
const rect = element.getBoundingClientRect()
|
|
762
|
+
return { id: tab.id, mid: rect.left + rect.width / 2 }
|
|
763
|
+
})
|
|
764
|
+
.filter((candidate) => candidate !== null)
|
|
765
|
+
if (candidates.length < 2) return
|
|
766
|
+
dragRef.current = { id, pinned, startX: event.clientX, moved: false, candidates, drop: null }
|
|
767
|
+
const stop = () => {
|
|
768
|
+
if (dragCleanupRef.current !== stop) return
|
|
769
|
+
window.removeEventListener('pointermove', onMove)
|
|
770
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
771
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
772
|
+
window.removeEventListener('blur', onCancel)
|
|
773
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
774
|
+
dragCleanupRef.current = null
|
|
775
|
+
}
|
|
776
|
+
const onCancel = () => {
|
|
777
|
+
stop()
|
|
778
|
+
dragRef.current = null
|
|
779
|
+
setDragState(null)
|
|
780
|
+
}
|
|
781
|
+
const onKeyDown = (keyEvent) => {
|
|
782
|
+
if (keyEvent.key === 'Escape') {
|
|
783
|
+
keyEvent.preventDefault()
|
|
784
|
+
onCancel()
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const onMove = (moveEvent) => {
|
|
788
|
+
const drag = dragRef.current
|
|
789
|
+
if (drag === null) return
|
|
790
|
+
if (!drag.moved) {
|
|
791
|
+
if (Math.abs(moveEvent.clientX - drag.startX) < 5) return
|
|
792
|
+
drag.moved = true
|
|
793
|
+
selectGuardRef.current = performance.now() + 500
|
|
794
|
+
}
|
|
795
|
+
if (moveEvent.cancelable) moveEvent.preventDefault()
|
|
796
|
+
const remaining = drag.candidates.filter((candidate) => candidate.id !== drag.id)
|
|
797
|
+
if (remaining.length === 0) return
|
|
798
|
+
const insertIndex = remaining.findIndex((candidate) => moveEvent.clientX < candidate.mid)
|
|
799
|
+
const reference = insertIndex < 0 ? remaining[remaining.length - 1] : remaining[insertIndex]
|
|
800
|
+
const place = insertIndex < 0 ? 'after' : 'before'
|
|
801
|
+
const previous = drag.drop
|
|
802
|
+
if (previous === null || previous.referenceId !== reference.id || previous.place !== place) {
|
|
803
|
+
drag.drop = { referenceId: reference.id, place }
|
|
804
|
+
setDragState({ id: drag.id, referenceId: reference.id, place })
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const onUp = () => {
|
|
808
|
+
const drag = dragRef.current
|
|
809
|
+
stop()
|
|
810
|
+
dragRef.current = null
|
|
811
|
+
setDragState(null)
|
|
812
|
+
if (drag === null || !drag.moved || drag.drop === null) return
|
|
813
|
+
try {
|
|
814
|
+
if (drag.pinned) actions.store.movePinned(drag.id, drag.drop.referenceId, drag.drop.place)
|
|
815
|
+
else actions.store.moveUnpinned(drag.id, drag.drop.referenceId, drag.drop.place)
|
|
816
|
+
} catch (error) {
|
|
817
|
+
console.error('dsh-tab: reordering tabs failed', error)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
dragCleanupRef.current = stop
|
|
821
|
+
window.addEventListener('pointermove', onMove, { passive: false })
|
|
822
|
+
window.addEventListener('pointerup', onUp, true)
|
|
823
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
824
|
+
window.addEventListener('blur', onCancel)
|
|
825
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// 外部导航(侧栏点会话 / 新建会话 / 关闭后自动落位)打断待切换
|
|
829
|
+
useEffect(() => {
|
|
830
|
+
if (pendingSelect === null) return
|
|
831
|
+
if (activeId === pendingSelect.id) return
|
|
832
|
+
if (activeId !== pendingSelect.fromId) clearPendingSelect()
|
|
833
|
+
}, [activeId, pendingSelect, clearPendingSelect])
|
|
834
|
+
|
|
835
|
+
useEffect(
|
|
836
|
+
() => () => {
|
|
837
|
+
if (pendingTimerRef.current !== null) clearTimeout(pendingTimerRef.current)
|
|
838
|
+
},
|
|
839
|
+
[],
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
// 当前标签滚入可见范围(只滚标签条自身,不滚页面);切换标签时平滑滚动,
|
|
843
|
+
// 标签条因侧栏拖动变宽变窄时直接对齐。
|
|
844
|
+
useEffect(() => {
|
|
845
|
+
const strip = stripRef.current
|
|
846
|
+
if (strip === null) return undefined
|
|
847
|
+
const reduceMotion =
|
|
848
|
+
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
849
|
+
const revealActive = (smooth) => {
|
|
850
|
+
const active = strip.querySelector('[aria-selected="true"]')
|
|
851
|
+
if (active === null) return
|
|
852
|
+
const left = active.offsetLeft
|
|
853
|
+
const right = left + active.offsetWidth
|
|
854
|
+
let target = strip.scrollLeft
|
|
855
|
+
if (left < strip.scrollLeft) target = Math.max(0, left - 8)
|
|
856
|
+
else if (right > strip.scrollLeft + strip.clientWidth) target = right - strip.clientWidth + 8
|
|
857
|
+
if (Math.abs(target - strip.scrollLeft) < 1) return
|
|
858
|
+
if (!smooth || reduceMotion || typeof strip.scrollTo !== 'function') strip.scrollLeft = target
|
|
859
|
+
else strip.scrollTo({ left: target, behavior: 'smooth' })
|
|
860
|
+
}
|
|
861
|
+
revealActive(true)
|
|
862
|
+
if (typeof ResizeObserver !== 'function') return undefined
|
|
863
|
+
const observer = new ResizeObserver(() => revealActive(false))
|
|
864
|
+
observer.observe(strip)
|
|
865
|
+
return () => observer.disconnect()
|
|
866
|
+
}, [visualActiveId, tabs.length])
|
|
867
|
+
|
|
868
|
+
// 标签栏在 shell.overlay 里绝对定位:左边缘跟住左栏宽度(左栏折叠时为 0,
|
|
869
|
+
// 桌面端折叠侧栏时由 CSS 的 clearance 变量继续右移,避开窗口按钮)
|
|
870
|
+
useEffect(() => {
|
|
871
|
+
const bar = barRef.current
|
|
872
|
+
const overlay = bar === null ? null : bar.closest('[data-shell-overlay]')
|
|
873
|
+
const frame = overlay === null ? null : overlay.parentElement
|
|
874
|
+
if (frame === null) return undefined
|
|
875
|
+
let sidebarCol = null
|
|
876
|
+
const resolveSidebarCol = () => {
|
|
877
|
+
const sidebarSlot = document.querySelector('[data-slot="sidebar"]')
|
|
878
|
+
if (sidebarSlot === null) return null
|
|
879
|
+
return [...frame.children].find((el) => el.contains(sidebarSlot)) ?? null
|
|
880
|
+
}
|
|
881
|
+
const sync = () => {
|
|
882
|
+
if (sidebarCol !== null && !sidebarCol.isConnected) sidebarCol = null
|
|
883
|
+
if (sidebarCol === null) {
|
|
884
|
+
sidebarCol = resolveSidebarCol()
|
|
885
|
+
if (sidebarCol !== null && observer !== undefined) observer.observe(sidebarCol)
|
|
886
|
+
}
|
|
887
|
+
const width = sidebarCol === null ? 0 : sidebarCol.getBoundingClientRect().width
|
|
888
|
+
bar.style.setProperty('--dsh-tab-left', `${Math.round(width)}px`)
|
|
889
|
+
}
|
|
890
|
+
let observer
|
|
891
|
+
if (typeof ResizeObserver === 'function') {
|
|
892
|
+
observer = new ResizeObserver(sync)
|
|
893
|
+
observer.observe(frame)
|
|
894
|
+
}
|
|
895
|
+
sidebarCol = resolveSidebarCol()
|
|
896
|
+
if (sidebarCol !== null && observer !== undefined) observer.observe(sidebarCol)
|
|
897
|
+
sync()
|
|
898
|
+
// 座位可能比 sidebar 晚挂载:补几次重试,避免左缘永远算成 0
|
|
899
|
+
const retryTimers = [100, 500, 1500].map((delay) => setTimeout(sync, delay))
|
|
900
|
+
return () => {
|
|
901
|
+
for (const timer of retryTimers) clearTimeout(timer)
|
|
902
|
+
if (observer !== undefined) observer.disconnect()
|
|
903
|
+
}
|
|
904
|
+
}, [])
|
|
905
|
+
|
|
906
|
+
// 菜单指向的标签被关掉 / 归档后自动收起
|
|
907
|
+
useEffect(() => {
|
|
908
|
+
if (menu === null) return
|
|
909
|
+
if (!tabs.some((tab) => tab.id === menu.tabId)) setMenu(null)
|
|
910
|
+
}, [menu, tabs])
|
|
911
|
+
|
|
912
|
+
const dismissMenu = useCallback(() => setMenu(null), [])
|
|
913
|
+
const openMenu = useCallback((event, tabId) => {
|
|
914
|
+
event.preventDefault()
|
|
915
|
+
event.stopPropagation()
|
|
916
|
+
setMenu({ x: event.clientX, y: event.clientY, tabId, step: 'list', error: undefined })
|
|
917
|
+
}, [])
|
|
918
|
+
|
|
919
|
+
const menuTabIndex = menu === null ? -1 : tabs.findIndex((tab) => tab.id === menu.tabId)
|
|
920
|
+
const menuTab = menuTabIndex < 0 ? undefined : tabs[menuTabIndex]
|
|
921
|
+
|
|
922
|
+
const runMenu = (fn) => () => {
|
|
923
|
+
dismissMenu()
|
|
924
|
+
fn()
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const startArchive = (stopActivity) => {
|
|
928
|
+
if (menu === null) return
|
|
929
|
+
const tabId = menu.tabId
|
|
930
|
+
setMenu((current) => (current === null ? current : { ...current, step: 'busy', error: undefined }))
|
|
931
|
+
actions.archive(tabId, stopActivity).then((result) => {
|
|
932
|
+
if (result.ok) {
|
|
933
|
+
setMenu((current) => (current !== null && current.tabId === tabId ? null : current))
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
setMenu((current) =>
|
|
937
|
+
current !== null && current.tabId === tabId ? { ...current, step: 'error', error: result.message } : current,
|
|
938
|
+
)
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// 方向键在标签之间移动并激活(自动激活式 tablist)。
|
|
943
|
+
// 切换会话后官方会把焦点交给输入框,所以这里延后一两帧把焦点抢回标签。
|
|
944
|
+
const focusRelative = (id, direction) => {
|
|
945
|
+
if (tabs.length < 2) return
|
|
946
|
+
const index = tabs.findIndex((tab) => tab.id === id)
|
|
947
|
+
if (index < 0) return
|
|
948
|
+
const target = tabs[(index + direction + tabs.length) % tabs.length]
|
|
949
|
+
if (target === undefined) return
|
|
950
|
+
requestSelect(target.id)
|
|
951
|
+
const focusTarget = () => {
|
|
952
|
+
const strip = stripRef.current
|
|
953
|
+
if (strip === null) return
|
|
954
|
+
const element = strip.querySelector(`[data-session-id="${target.id}"]`)
|
|
955
|
+
if (element !== null && typeof element.focus === 'function') element.focus({ preventScroll: true })
|
|
956
|
+
}
|
|
957
|
+
focusTarget()
|
|
958
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
959
|
+
requestAnimationFrame(() => {
|
|
960
|
+
focusTarget()
|
|
961
|
+
setTimeout(focusTarget, 60)
|
|
962
|
+
})
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
let menuHint
|
|
967
|
+
let menuItems = []
|
|
968
|
+
if (menu !== null) {
|
|
969
|
+
if (menu.step === 'busy') {
|
|
970
|
+
menuHint = t('menu.archiveBusy')
|
|
971
|
+
} else if (menu.step === 'error') {
|
|
972
|
+
menuHint = t('menu.archiveFailed', { message: menu.error ?? '' })
|
|
973
|
+
menuItems = [
|
|
974
|
+
{ key: 'retry-stop', label: t('menu.retryStop'), danger: true, run: () => startArchive(true) },
|
|
975
|
+
{ key: 'cancel', label: t('menu.cancel'), run: dismissMenu },
|
|
976
|
+
]
|
|
977
|
+
} else if (menu.step === 'archive-confirm') {
|
|
978
|
+
menuHint = t('menu.archiveRunning')
|
|
979
|
+
menuItems = [
|
|
980
|
+
{ key: 'archive-stop', label: t('menu.archiveStop'), danger: true, run: () => startArchive(true) },
|
|
981
|
+
{ key: 'cancel', label: t('menu.cancel'), run: dismissMenu },
|
|
982
|
+
]
|
|
983
|
+
} else if (menuTab !== undefined) {
|
|
984
|
+
const rightCount = tabs.length - menuTabIndex - 1
|
|
985
|
+
const otherCount = tabs.filter((tab) => tab.id !== menuTab.id && !tab.pinned).length
|
|
986
|
+
menuItems = [
|
|
987
|
+
{ key: 'close', label: t('menu.close'), run: runMenu(() => actions.closeOne(menuTab.id)) },
|
|
988
|
+
{
|
|
989
|
+
key: 'close-right',
|
|
990
|
+
label: t('menu.closeRight'),
|
|
991
|
+
disabled: rightCount === 0,
|
|
992
|
+
run: runMenu(() => actions.closeRight(menuTab.id)),
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
key: 'close-others',
|
|
996
|
+
label: t('menu.closeOthers'),
|
|
997
|
+
disabled: otherCount === 0,
|
|
998
|
+
run: runMenu(() => actions.closeOthers(menuTab.id)),
|
|
999
|
+
},
|
|
1000
|
+
{ key: 'sep-1', separator: true },
|
|
1001
|
+
{ key: 'close-all', label: t('menu.closeAll'), run: runMenu(() => actions.closeAll()) },
|
|
1002
|
+
{ key: 'sep-2', separator: true },
|
|
1003
|
+
menuTab.pinned
|
|
1004
|
+
? { key: 'unpin', label: t('menu.unpin'), run: runMenu(() => actions.unpin(menuTab.id)) }
|
|
1005
|
+
: { key: 'pin', label: t('menu.pin'), run: runMenu(() => actions.pin(menuTab.id)) },
|
|
1006
|
+
{ key: 'sep-3', separator: true },
|
|
1007
|
+
{
|
|
1008
|
+
key: 'archive',
|
|
1009
|
+
label: t('menu.archive'),
|
|
1010
|
+
danger: true,
|
|
1011
|
+
run: runMenu(() => {
|
|
1012
|
+
const status = statuses?.get(menuTab.id)
|
|
1013
|
+
const busy =
|
|
1014
|
+
status?.running === true ||
|
|
1015
|
+
(status?.pendingInteraction !== undefined && status?.pendingInteraction !== null)
|
|
1016
|
+
if (busy) setMenu({ x: menu.x, y: menu.y, tabId: menuTab.id, step: 'archive-confirm', error: undefined })
|
|
1017
|
+
else startArchive(false)
|
|
1018
|
+
}),
|
|
1019
|
+
},
|
|
1020
|
+
]
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return h(
|
|
1025
|
+
React.Fragment,
|
|
1026
|
+
null,
|
|
1027
|
+
h(
|
|
1028
|
+
'div',
|
|
1029
|
+
{
|
|
1030
|
+
ref: barRef,
|
|
1031
|
+
className: 'dsh-tab-pagebar',
|
|
1032
|
+
'data-dsh-tab-bar': '',
|
|
1033
|
+
'data-tabs': String(tabs.length),
|
|
1034
|
+
'data-empty': tabs.length === 0 ? 'true' : 'false',
|
|
1035
|
+
'data-window-drag': '',
|
|
1036
|
+
},
|
|
1037
|
+
h('style', { key: 'style' }, CSS),
|
|
1038
|
+
h(
|
|
1039
|
+
'div',
|
|
1040
|
+
{
|
|
1041
|
+
key: 'strip',
|
|
1042
|
+
ref: stripRef,
|
|
1043
|
+
className: 'dsh-tab-pagebar__strip',
|
|
1044
|
+
role: 'tablist',
|
|
1045
|
+
'aria-label': t('tab.list'),
|
|
1046
|
+
},
|
|
1047
|
+
tabs.flatMap((tab, index) => {
|
|
1048
|
+
const row = rows[tab.id]
|
|
1049
|
+
const knownBadge = workspaceBadges.get(tab.id)
|
|
1050
|
+
const cwdLabel = basenameOf(row?.cwd)
|
|
1051
|
+
const badge = knownBadge ?? (cwdLabel.length === 0 ? null : { label: cwdLabel, hue: workspaceHue(cwdLabel) })
|
|
1052
|
+
const node = h(TabItem, {
|
|
1053
|
+
key: tab.id,
|
|
1054
|
+
id: tab.id,
|
|
1055
|
+
active: tab.id === visualActiveId,
|
|
1056
|
+
blank: row?.blank === true,
|
|
1057
|
+
pinned: tab.pinned,
|
|
1058
|
+
badge,
|
|
1059
|
+
dragging: dragState !== null && dragState.id === tab.id,
|
|
1060
|
+
drop: dragState !== null && dragState.referenceId === tab.id ? dragState.place : null,
|
|
1061
|
+
title: row?.blank === true ? t('tab.newSession') : row?.displayTitle || row?.title || t('tab.untitled'),
|
|
1062
|
+
signal: sessionStatusSignal(statuses?.get(tab.id)),
|
|
1063
|
+
t,
|
|
1064
|
+
onSelect: onTabSelect,
|
|
1065
|
+
onClose: actions.closeOne,
|
|
1066
|
+
onMenu: openMenu,
|
|
1067
|
+
onArrow: focusRelative,
|
|
1068
|
+
onDragStart: (event) => beginDrag(event, tab.id, tab.pinned),
|
|
1069
|
+
})
|
|
1070
|
+
const isLastPinned = tab.pinned && index === pinnedCount - 1 && tabs.length > pinnedCount
|
|
1071
|
+
return isLastPinned
|
|
1072
|
+
? [node, h('span', { key: 'divider', className: 'dsh-tab-pagebar__divider', 'aria-hidden': true })]
|
|
1073
|
+
: [node]
|
|
1074
|
+
}),
|
|
1075
|
+
),
|
|
1076
|
+
h(
|
|
1077
|
+
'button',
|
|
1078
|
+
{
|
|
1079
|
+
key: 'new',
|
|
1080
|
+
type: 'button',
|
|
1081
|
+
className: 'dsh-tab-pagebar__new',
|
|
1082
|
+
title: t('tab.new'),
|
|
1083
|
+
'aria-label': t('tab.new'),
|
|
1084
|
+
onClick: actions.create,
|
|
1085
|
+
},
|
|
1086
|
+
h(IconPlus),
|
|
1087
|
+
),
|
|
1088
|
+
),
|
|
1089
|
+
menu === null
|
|
1090
|
+
? null
|
|
1091
|
+
: h(TabMenu, {
|
|
1092
|
+
key: 'menu',
|
|
1093
|
+
x: menu.x,
|
|
1094
|
+
y: menu.y,
|
|
1095
|
+
hint: menuHint,
|
|
1096
|
+
items: menuItems,
|
|
1097
|
+
onClose: dismissMenu,
|
|
1098
|
+
}),
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// ── 插件入口 ────────────────────────────────────────────────────────────
|
|
1104
|
+
return {
|
|
1105
|
+
inject: ['slots', 'locale', 'sessions', 'uiWorkspace', 'workspaces'],
|
|
1106
|
+
apply(ctx) {
|
|
1107
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-tab: dictionaries')
|
|
1108
|
+
|
|
1109
|
+
// 帧布局样式:让 shell.bottom 这行排到三栏上方、横跨会话列 + 右栏列
|
|
1110
|
+
ctx.effect(() => {
|
|
1111
|
+
const style = document.createElement('style')
|
|
1112
|
+
style.dataset.plugin = 'dsh-tab'
|
|
1113
|
+
style.dataset.pluginCss = 'dsh-tab/frame.css'
|
|
1114
|
+
style.textContent = FRAME_CSS
|
|
1115
|
+
document.head.appendChild(style)
|
|
1116
|
+
return () => style.remove()
|
|
1117
|
+
}, 'dsh-tab: frame layout')
|
|
1118
|
+
|
|
1119
|
+
const sessions = ctx.sessions
|
|
1120
|
+
const uiWorkspace = ctx.uiWorkspace
|
|
1121
|
+
const store = createTabStore()
|
|
1122
|
+
|
|
1123
|
+
/** 当前显示顺序(与渲染层同一套过滤规则,供关闭操作计算左右邻位)。 */
|
|
1124
|
+
const tabsNow = () => {
|
|
1125
|
+
const catalog = sessions.list.getSnapshot()
|
|
1126
|
+
const rows = catalog?.byId ?? {}
|
|
1127
|
+
const archived = new Set(ctx.workspaces.list.getSnapshot().archivedSessionIds ?? [])
|
|
1128
|
+
return displayTabs(store.getSnapshot()).filter((tab) => rows[tab.id] !== undefined && !archived.has(tab.id))
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// 关掉当前标签后 official 「新建会话」会复用空白会话,导航完成时当前会话可能不变,
|
|
1132
|
+
// 这种情形下补一次「当前会话入签」,避免标签栏空着。
|
|
1133
|
+
const ensureTimers = new Set()
|
|
1134
|
+
ctx.effect(
|
|
1135
|
+
() => () => {
|
|
1136
|
+
for (const timer of ensureTimers) clearTimeout(timer)
|
|
1137
|
+
ensureTimers.clear()
|
|
1138
|
+
},
|
|
1139
|
+
'dsh-tab: settle timers',
|
|
1140
|
+
)
|
|
1141
|
+
const scheduleEnsureCurrentTab = (closedCurrent) => {
|
|
1142
|
+
for (const delay of [500, 1500]) {
|
|
1143
|
+
const timer = setTimeout(() => {
|
|
1144
|
+
ensureTimers.delete(timer)
|
|
1145
|
+
try {
|
|
1146
|
+
const current = currentSessionIdOf(sessions.list.getSnapshot())
|
|
1147
|
+
if (current === undefined) return
|
|
1148
|
+
// 导航还没完成时当前会话仍是刚被关掉的旧会话:不要把它加回来
|
|
1149
|
+
if (current === closedCurrent) return
|
|
1150
|
+
store.unsuppress(current)
|
|
1151
|
+
if (!store.has(current)) store.add(current)
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
console.error('dsh-tab: settling current tab failed', error)
|
|
1154
|
+
}
|
|
1155
|
+
}, delay)
|
|
1156
|
+
ensureTimers.add(timer)
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** 关掉一批标签;当前会话在被关列表里时按 nextId / 首个剩余标签 / 新建会话的次序落位。 */
|
|
1161
|
+
const closeIds = (idsToClose, nextId) => {
|
|
1162
|
+
if (idsToClose.length === 0) return
|
|
1163
|
+
const catalog = sessions.list.getSnapshot()
|
|
1164
|
+
const current = currentSessionIdOf(catalog)
|
|
1165
|
+
const currentRow = current === undefined ? undefined : catalog?.byId?.[current]
|
|
1166
|
+
const allTabs = displayTabs(store.getSnapshot())
|
|
1167
|
+
const closingAll = allTabs.every((tab) => idsToClose.includes(tab.id))
|
|
1168
|
+
// 全关但当前就是一个空白「新会话」:官方「新建会话」只会把同一个空白会话再打开,
|
|
1169
|
+
// 所以直接保留它当新的新会话页,避免标签栏空着或导航竞态把旧标签加回来。
|
|
1170
|
+
if (closingAll && current !== undefined && idsToClose.includes(current) && currentRow?.blank === true) {
|
|
1171
|
+
const others = idsToClose.filter((id) => id !== current)
|
|
1172
|
+
for (const id of others) store.suppress(id)
|
|
1173
|
+
store.removeMany(others)
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1176
|
+
for (const id of idsToClose) store.suppress(id)
|
|
1177
|
+
store.removeMany(idsToClose)
|
|
1178
|
+
if (current === undefined || !idsToClose.includes(current)) return
|
|
1179
|
+
if (nextId !== undefined && store.has(nextId) && nextId !== current) {
|
|
1180
|
+
uiWorkspace.openSession(nextId)
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
const remaining = tabsNow()
|
|
1184
|
+
if (remaining.length > 0) uiWorkspace.openSession(remaining[0].id)
|
|
1185
|
+
else {
|
|
1186
|
+
uiWorkspace.startSession()
|
|
1187
|
+
scheduleEnsureCurrentTab(current)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const actions = {
|
|
1192
|
+
store,
|
|
1193
|
+
select(id) {
|
|
1194
|
+
try {
|
|
1195
|
+
const current = currentSessionIdOf(sessions.list.getSnapshot())
|
|
1196
|
+
if (current === id) return
|
|
1197
|
+
uiWorkspace.openSession(id)
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
console.error('dsh-tab: switching conversation failed', error)
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
closeOne(id) {
|
|
1203
|
+
try {
|
|
1204
|
+
const tabs = tabsNow()
|
|
1205
|
+
const index = tabs.findIndex((tab) => tab.id === id)
|
|
1206
|
+
const next = tabs[index + 1]?.id ?? tabs[index - 1]?.id
|
|
1207
|
+
closeIds([id], next)
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
console.error('dsh-tab: closing tab failed', error)
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
closeRight(id) {
|
|
1213
|
+
try {
|
|
1214
|
+
const tabs = tabsNow()
|
|
1215
|
+
const index = tabs.findIndex((tab) => tab.id === id)
|
|
1216
|
+
if (index < 0) return
|
|
1217
|
+
closeIds(
|
|
1218
|
+
tabs.slice(index + 1).map((tab) => tab.id),
|
|
1219
|
+
id,
|
|
1220
|
+
)
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
console.error('dsh-tab: closing tabs to the right failed', error)
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
closeOthers(id) {
|
|
1226
|
+
try {
|
|
1227
|
+
const closing = tabsNow().filter((tab) => tab.id !== id && !tab.pinned).map((tab) => tab.id)
|
|
1228
|
+
closeIds(closing, id)
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
console.error('dsh-tab: closing other tabs failed', error)
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
closeAll() {
|
|
1234
|
+
try {
|
|
1235
|
+
const all = store.getSnapshot().ids
|
|
1236
|
+
closeIds(all, undefined)
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
console.error('dsh-tab: closing all tabs failed', error)
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
pin(id) {
|
|
1242
|
+
store.pin(id)
|
|
1243
|
+
},
|
|
1244
|
+
unpin(id) {
|
|
1245
|
+
store.unpin(id)
|
|
1246
|
+
},
|
|
1247
|
+
archive(sessionId, stopActivity) {
|
|
1248
|
+
return uiWorkspace
|
|
1249
|
+
.archiveSession(sessionId, stopActivity === true ? { stopActivity: true } : undefined)
|
|
1250
|
+
.then(() => {
|
|
1251
|
+
store.suppress(sessionId)
|
|
1252
|
+
store.removeMany([sessionId])
|
|
1253
|
+
return { ok: true }
|
|
1254
|
+
})
|
|
1255
|
+
.catch((error) => {
|
|
1256
|
+
console.error('dsh-tab: archiving conversation failed', error)
|
|
1257
|
+
return { ok: false, message: errorMessageOf(error) }
|
|
1258
|
+
})
|
|
1259
|
+
},
|
|
1260
|
+
create() {
|
|
1261
|
+
try {
|
|
1262
|
+
uiWorkspace.startSession()
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
console.error('dsh-tab: creating conversation failed', error)
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const TabBar = createTabBar(actions)
|
|
1270
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1271
|
+
ctx.slots.register(
|
|
1272
|
+
{
|
|
1273
|
+
name: 'shell.overlay',
|
|
1274
|
+
id: 'dsh-tab-bar',
|
|
1275
|
+
order: -100,
|
|
1276
|
+
locale: NS,
|
|
1277
|
+
},
|
|
1278
|
+
TabBar,
|
|
1279
|
+
),
|
|
1280
|
+
)
|
|
1281
|
+
},
|
|
1282
|
+
}
|
|
1283
|
+
},
|
|
1284
|
+
})
|