dsh-better-workspace 0.4.2
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/LICENSE +21 -0
- package/README.md +118 -0
- package/README_EN.md +100 -0
- package/cordis.patch.yml +22 -0
- package/dsh.plugin.json +13 -0
- package/package.json +66 -0
- package/src/client.js +2053 -0
- package/src/index.js +50 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,2053 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-better-workspace — client half (plain JavaScript, no build step).
|
|
3
|
+
*
|
|
4
|
+
* Three registrations:
|
|
5
|
+
* 1. `sidebar.workspaces` (priority -1): replaces the shipped workspace
|
|
6
|
+
* browser with a hierarchy tree derived from "/" inside workspace titles.
|
|
7
|
+
* 2. `conversation.hero.workspace.directoryFlow`: the add-workspace picking
|
|
8
|
+
* interaction for the conversation empty-state menu — native directory
|
|
9
|
+
* pick, then a parent-group popup, then create + rename with the prefix.
|
|
10
|
+
* 3. `sidebar.workspaces.directoryFlow`: the same interaction for the
|
|
11
|
+
* shipped sidebar browser (fills only when that hole is declared, i.e.
|
|
12
|
+
* whenever this plugin's own browser is not the occupying entry).
|
|
13
|
+
*
|
|
14
|
+
* Every require below is a dsh client baseline module (see
|
|
15
|
+
* @deepseek-ai/dsh-client-web seed.ts): react, @deepseek-ai/dsh-client-store,
|
|
16
|
+
* @deepseek-ai/dsh-client-ui-primitives.
|
|
17
|
+
*/
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-better-workspace',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
const React = require('react')
|
|
22
|
+
const storeKit = require('@deepseek-ai/dsh-client-store')
|
|
23
|
+
const ui = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
24
|
+
|
|
25
|
+
const E = React.createElement
|
|
26
|
+
const NS = 'betterWorkspace'
|
|
27
|
+
|
|
28
|
+
/* ============================== i18n ============================== */
|
|
29
|
+
|
|
30
|
+
const zh = {
|
|
31
|
+
'title': '工作区',
|
|
32
|
+
'search.placeholder': '搜索工作区或会话',
|
|
33
|
+
'add': '添加工作区',
|
|
34
|
+
'newFolder': '新建分组',
|
|
35
|
+
'rail.search': '搜索',
|
|
36
|
+
'rail.add': '添加工作区',
|
|
37
|
+
'empty': '暂无工作区',
|
|
38
|
+
'empty.search': '没有匹配的结果',
|
|
39
|
+
'session.new': '新会话',
|
|
40
|
+
'group.ungrouped': '未分组',
|
|
41
|
+
'sessions.expand': '展开 {n} 个会话',
|
|
42
|
+
'sessions.collapse': '收起',
|
|
43
|
+
'time.now': '刚刚',
|
|
44
|
+
'time.minutes': '{n} 分钟',
|
|
45
|
+
'time.hours': '{n} 小时',
|
|
46
|
+
'time.days': '{n} 天',
|
|
47
|
+
'time.months': '{n} 个月',
|
|
48
|
+
'time.years': '{n} 年',
|
|
49
|
+
'status.running': '生成中',
|
|
50
|
+
'status.completed': '已完成',
|
|
51
|
+
'status.approval': '等待批准',
|
|
52
|
+
'status.planReview': '等待计划确认',
|
|
53
|
+
'status.question': '等待回答',
|
|
54
|
+
'status.subagents': '{n} 个子任务运行中',
|
|
55
|
+
'menu.rename': '重命名',
|
|
56
|
+
'menu.delete': '删除',
|
|
57
|
+
'menu.fork': '分叉',
|
|
58
|
+
'menu.archive': '归档',
|
|
59
|
+
'menu.renameFolder': '重命名分组',
|
|
60
|
+
'menu.removeFolder': '删除分组',
|
|
61
|
+
'menu.renameSgroup': '重命名会话分组',
|
|
62
|
+
'settings.title': '更好的工作区',
|
|
63
|
+
'settings.desc': '工作区树的外观与折叠偏好',
|
|
64
|
+
'settings.expand': '展开',
|
|
65
|
+
'settings.collapse': '收起',
|
|
66
|
+
'settings.compactChains': '单链分组折叠显示',
|
|
67
|
+
'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;展开状态与自定义外观保存在当前浏览器。',
|
|
68
|
+
'custom.title': '自定义外观',
|
|
69
|
+
'custom.color': '颜色',
|
|
70
|
+
'custom.glow': '发光',
|
|
71
|
+
'custom.preview': '实时预览',
|
|
72
|
+
'custom.preview.sample': '工作区示例',
|
|
73
|
+
'custom.weight': '字体粗细',
|
|
74
|
+
'custom.weight.regular': '常规',
|
|
75
|
+
'custom.weight.medium': '中',
|
|
76
|
+
'custom.weight.semibold': '半粗',
|
|
77
|
+
'custom.weight.bold': '粗',
|
|
78
|
+
'custom.shadow': '字体阴影',
|
|
79
|
+
'custom.weak': '弱',
|
|
80
|
+
'custom.medium': '中',
|
|
81
|
+
'custom.strong': '强',
|
|
82
|
+
'custom.icon': '图标',
|
|
83
|
+
'custom.icon.solid': '实心文件夹',
|
|
84
|
+
'custom.icon.outline': '空心文件夹',
|
|
85
|
+
'custom.icon.none': '不显示',
|
|
86
|
+
'custom.none': '不显示',
|
|
87
|
+
'custom.reset': '清除自定义',
|
|
88
|
+
'custom.done': '完成',
|
|
89
|
+
'settings.on': '开',
|
|
90
|
+
'settings.off': '关',
|
|
91
|
+
'flow.title': '添加工作区',
|
|
92
|
+
'flow.picked': '所选文件夹',
|
|
93
|
+
'flow.parent': '所属分组',
|
|
94
|
+
'flow.parentHint': '输入或下拉选择分组路径,留空表示根分组;多级用 / 分隔',
|
|
95
|
+
'flow.creating': '正在创建…',
|
|
96
|
+
'error.title': '出错了',
|
|
97
|
+
'cancel': '取消',
|
|
98
|
+
'create': '创建',
|
|
99
|
+
'confirm': '确定',
|
|
100
|
+
'close': '关闭',
|
|
101
|
+
'ws.rename.title': '重命名工作区',
|
|
102
|
+
'ws.rename.hint': '名称中的 / 即层级分组,例如 web/前端',
|
|
103
|
+
'ws.delete.title': '删除工作区',
|
|
104
|
+
'ws.delete.body': '仅移除工作区登记,目录和会话记录都会保留。确定删除「{name}」?',
|
|
105
|
+
'folder.new.title': '新建分组',
|
|
106
|
+
'folder.new.hint': '分组路径,可用 / 表示多级,例如 web/前端',
|
|
107
|
+
'folder.rename.title': '重命名分组',
|
|
108
|
+
'folder.rename.hint': '重命名会同步更新组内所有工作区名称',
|
|
109
|
+
'folder.delete.body': '删除空分组「{name}」?',
|
|
110
|
+
'folder.error.empty': '分组路径不能为空',
|
|
111
|
+
'folder.error.exists': '分组已存在',
|
|
112
|
+
'folder.error.notEmpty': '分组内还有工作区,无法删除',
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const en = {
|
|
116
|
+
'title': 'Workspaces',
|
|
117
|
+
'search.placeholder': 'Search workspaces or sessions',
|
|
118
|
+
'add': 'Add workspace',
|
|
119
|
+
'newFolder': 'New folder',
|
|
120
|
+
'rail.search': 'Search',
|
|
121
|
+
'rail.add': 'Add workspace',
|
|
122
|
+
'empty': 'No workspaces yet',
|
|
123
|
+
'empty.search': 'No matches',
|
|
124
|
+
'session.new': 'New session',
|
|
125
|
+
'group.ungrouped': 'Ungrouped',
|
|
126
|
+
'sessions.expand': 'Show {n} more sessions',
|
|
127
|
+
'sessions.collapse': 'Collapse',
|
|
128
|
+
'time.now': 'now',
|
|
129
|
+
'time.minutes': '{n}min',
|
|
130
|
+
'time.hours': '{n}h',
|
|
131
|
+
'time.days': '{n}d',
|
|
132
|
+
'time.months': '{n}mo',
|
|
133
|
+
'time.years': '{n}y',
|
|
134
|
+
'status.running': 'Running',
|
|
135
|
+
'status.completed': 'Completed',
|
|
136
|
+
'status.approval': 'Waiting for approval',
|
|
137
|
+
'status.planReview': 'Waiting for plan review',
|
|
138
|
+
'status.question': 'Waiting for answer',
|
|
139
|
+
'status.subagents': '{n} subagent(s) running',
|
|
140
|
+
'menu.rename': 'Rename',
|
|
141
|
+
'menu.delete': 'Delete',
|
|
142
|
+
'menu.fork': 'Fork',
|
|
143
|
+
'menu.archive': 'Archive',
|
|
144
|
+
'menu.renameFolder': 'Rename folder',
|
|
145
|
+
'menu.removeFolder': 'Delete folder',
|
|
146
|
+
'menu.renameSgroup': 'Rename session group',
|
|
147
|
+
'settings.title': 'Better Workspaces',
|
|
148
|
+
'settings.desc': 'Workspace tree appearance and folding preferences',
|
|
149
|
+
'settings.expand': 'Expand',
|
|
150
|
+
'settings.collapse': 'Collapse',
|
|
151
|
+
'settings.compactChains': 'Merge single-child chains',
|
|
152
|
+
'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. State and custom styling persist in this browser.',
|
|
153
|
+
'custom.title': 'Customize',
|
|
154
|
+
'custom.color': 'Color',
|
|
155
|
+
'custom.glow': 'Glow',
|
|
156
|
+
'custom.preview': 'Live preview',
|
|
157
|
+
'custom.preview.sample': 'Workspace sample',
|
|
158
|
+
'custom.weight': 'Font weight',
|
|
159
|
+
'custom.weight.regular': 'Regular',
|
|
160
|
+
'custom.weight.medium': 'Medium',
|
|
161
|
+
'custom.weight.semibold': 'Semi-bold',
|
|
162
|
+
'custom.weight.bold': 'Bold',
|
|
163
|
+
'custom.shadow': 'Font shadow',
|
|
164
|
+
'custom.weak': 'Subtle',
|
|
165
|
+
'custom.medium': 'Medium',
|
|
166
|
+
'custom.strong': 'Strong',
|
|
167
|
+
'custom.icon': 'Icon',
|
|
168
|
+
'custom.icon.solid': 'Solid folder',
|
|
169
|
+
'custom.icon.outline': 'Outline folder',
|
|
170
|
+
'custom.icon.none': 'Hidden',
|
|
171
|
+
'custom.none': 'None',
|
|
172
|
+
'custom.reset': 'Clear custom style',
|
|
173
|
+
'custom.done': 'Done',
|
|
174
|
+
'settings.on': 'On',
|
|
175
|
+
'settings.off': 'Off',
|
|
176
|
+
'flow.title': 'Add workspace',
|
|
177
|
+
'flow.picked': 'Chosen folder',
|
|
178
|
+
'flow.parent': 'Parent group',
|
|
179
|
+
'flow.parentHint': 'Type or pick a group path; empty means root. Nest with /',
|
|
180
|
+
'flow.creating': 'Creating…',
|
|
181
|
+
'error.title': 'Something went wrong',
|
|
182
|
+
'cancel': 'Cancel',
|
|
183
|
+
'create': 'Create',
|
|
184
|
+
'confirm': 'OK',
|
|
185
|
+
'close': 'Close',
|
|
186
|
+
'ws.rename.title': 'Rename workspace',
|
|
187
|
+
'ws.rename.hint': 'Use / inside the name to nest, e.g. web/frontend',
|
|
188
|
+
'ws.delete.title': 'Delete workspace',
|
|
189
|
+
'ws.delete.body': 'Only the workspace registration is removed; the directory and session logs remain. Delete "{name}"?',
|
|
190
|
+
'folder.new.title': 'New folder',
|
|
191
|
+
'folder.new.hint': 'Folder path; nest with /, e.g. web/frontend',
|
|
192
|
+
'folder.rename.title': 'Rename folder',
|
|
193
|
+
'folder.rename.hint': 'Renaming updates every workspace title inside the folder',
|
|
194
|
+
'folder.delete.body': 'Delete empty folder "{name}"?',
|
|
195
|
+
'folder.error.empty': 'Folder path must not be empty',
|
|
196
|
+
'folder.error.exists': 'Folder already exists',
|
|
197
|
+
'folder.error.notEmpty': 'Folder still contains workspaces',
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ============================= helpers ============================ */
|
|
201
|
+
|
|
202
|
+
const cls = (...xs) => xs.filter(Boolean).join(' ')
|
|
203
|
+
const messageOf = (reason) => (reason instanceof Error ? reason.message : String(reason))
|
|
204
|
+
|
|
205
|
+
const basename = (p) => {
|
|
206
|
+
if (!p) return ''
|
|
207
|
+
const s = String(p).replace(/[\\/]+$/, '')
|
|
208
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
|
|
209
|
+
return i === -1 ? s : s.slice(i + 1)
|
|
210
|
+
}
|
|
211
|
+
const splitTitleSegs = (title) => String(title || '').split('/').map(s => s.trim()).filter(Boolean)
|
|
212
|
+
const normPath = (p) => String(p || '').split('/').map(s => s.trim()).filter(Boolean).join('/')
|
|
213
|
+
|
|
214
|
+
/** Render a primitives icon by name; unknown names degrade to null, never crash. */
|
|
215
|
+
const icon = (name, size) => {
|
|
216
|
+
const C = ui[name]
|
|
217
|
+
return C ? E(C, { size: size || 16 }) : null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Folder-with-plus glyph for the new-group header button (absent from the primitives set). */
|
|
221
|
+
const FolderPlusIcon = (props) => E('svg', {
|
|
222
|
+
width: props.size || 16,
|
|
223
|
+
height: props.size || 16,
|
|
224
|
+
viewBox: '0 0 16 16',
|
|
225
|
+
fill: 'none',
|
|
226
|
+
stroke: 'currentColor',
|
|
227
|
+
strokeWidth: 1.2,
|
|
228
|
+
strokeLinecap: 'round',
|
|
229
|
+
strokeLinejoin: 'round',
|
|
230
|
+
'aria-hidden': 'true',
|
|
231
|
+
},
|
|
232
|
+
E('path', { d: 'M1.75 4.6c0-.74.6-1.35 1.35-1.35h2.5l1.4 1.6h4.9c.74 0 1.35.6 1.35 1.35v5.9c0 .74-.6 1.35-1.35 1.35H3.1c-.74 0-1.35-.6-1.35-1.35V4.6z' }),
|
|
233
|
+
E('path', { d: 'M8 7.2v3.6M6.2 9h3.6' }),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const FALLBACK_UNITS = { minutes: 'm', hours: 'h', days: 'd', months: 'mo', years: 'y' }
|
|
237
|
+
const timeLabel = (updatedAt, now, t) => {
|
|
238
|
+
if (typeof ui.relativeTime !== 'function') return ''
|
|
239
|
+
const r = ui.relativeTime(updatedAt, now)
|
|
240
|
+
if (!r) return ''
|
|
241
|
+
if (r.unit === 'now') return t('time.now')
|
|
242
|
+
const key = 'time.' + r.unit
|
|
243
|
+
const out = t(key, { n: r.n })
|
|
244
|
+
if (typeof out === 'string' && out !== '' && out !== key) return out
|
|
245
|
+
return String(r.n) + (FALLBACK_UNITS[r.unit] || '')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pendingKindOf = (pending, id) => {
|
|
249
|
+
if (!pending) return undefined
|
|
250
|
+
const p = typeof pending.get === 'function' ? pending.get(id) : pending[id]
|
|
251
|
+
if (!p) return undefined
|
|
252
|
+
return p.kind || p.status || p.type || 'pending'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const sessionTitleOf = (summary, t) => {
|
|
256
|
+
if (!summary) return ''
|
|
257
|
+
if (summary.blank) return t('session.new')
|
|
258
|
+
return String(summary.displayTitle || summary.title || '')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Official visibility rule (dsh tree.ts sessionVisible): subagent children
|
|
263
|
+
* live in their parent's catalog, archived sessions are visible nowhere,
|
|
264
|
+
* and a blank row is the provisional New Session of the current selection.
|
|
265
|
+
*/
|
|
266
|
+
const sessionVisible = (summary, current, archivedSet) => !!summary
|
|
267
|
+
&& summary.origin !== 'subagent'
|
|
268
|
+
&& !(archivedSet && archivedSet.has(summary.id))
|
|
269
|
+
&& (!summary.blank || summary.id === current)
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Running subagent descendants per session (light lineage walk over
|
|
273
|
+
* parentSessionId links) — a parent row keeps its "ongoing" ring while a
|
|
274
|
+
* spawned subagent is still working.
|
|
275
|
+
*/
|
|
276
|
+
const subagentRunningCounts = (byId) => {
|
|
277
|
+
const children = new Map()
|
|
278
|
+
for (const id of Object.keys(byId || {})) {
|
|
279
|
+
const summary = byId[id]
|
|
280
|
+
if (!summary || !summary.parentSessionId) continue
|
|
281
|
+
let list = children.get(summary.parentSessionId)
|
|
282
|
+
if (!list) { list = []; children.set(summary.parentSessionId, list) }
|
|
283
|
+
list.push(summary)
|
|
284
|
+
}
|
|
285
|
+
const countFor = (rootId) => {
|
|
286
|
+
let count = 0
|
|
287
|
+
const queue = (children.get(rootId) || []).slice()
|
|
288
|
+
const seen = new Set([rootId])
|
|
289
|
+
while (queue.length > 0) {
|
|
290
|
+
const summary = queue.shift()
|
|
291
|
+
if (!summary || seen.has(summary.id)) continue
|
|
292
|
+
seen.add(summary.id)
|
|
293
|
+
if (summary.running) count += 1
|
|
294
|
+
const kids = children.get(summary.id)
|
|
295
|
+
if (kids) for (const kid of kids) queue.push(kid)
|
|
296
|
+
}
|
|
297
|
+
return count
|
|
298
|
+
}
|
|
299
|
+
const counts = new Map()
|
|
300
|
+
for (const id of Object.keys(byId || {})) counts.set(id, countFor(id))
|
|
301
|
+
return counts
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Session nesting inside one workspace: same "/" convention as workspace
|
|
306
|
+
* titles. Groups are virtual (projection of names). Rows keep the Host
|
|
307
|
+
* workspace.sessionIds (manual) order — drag-to-reorder must be visible.
|
|
308
|
+
*/
|
|
309
|
+
function buildSessionTree(rows) {
|
|
310
|
+
const root = { path: '', name: '', groups: [], sessions: [] }
|
|
311
|
+
const byPath = new Map([['', root]])
|
|
312
|
+
const ensure = (path) => {
|
|
313
|
+
if (path === '') return root
|
|
314
|
+
const known = byPath.get(path)
|
|
315
|
+
if (known) return known
|
|
316
|
+
const segs = path.split('/')
|
|
317
|
+
const parent = ensure(segs.slice(0, -1).join('/'))
|
|
318
|
+
const node = { path, name: segs[segs.length - 1], groups: [], sessions: [] }
|
|
319
|
+
byPath.set(path, node)
|
|
320
|
+
parent.groups.push(node)
|
|
321
|
+
return node
|
|
322
|
+
}
|
|
323
|
+
for (const row of rows || []) {
|
|
324
|
+
const segs = splitTitleSegs(row.title)
|
|
325
|
+
const folderPath = segs.slice(0, -1).join('/')
|
|
326
|
+
const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
|
|
327
|
+
ensure(folderPath).sessions.push({ ...row, leaf })
|
|
328
|
+
}
|
|
329
|
+
const sortRec = (node) => {
|
|
330
|
+
node.groups.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
|
331
|
+
for (const group of node.groups) sortRec(group)
|
|
332
|
+
}
|
|
333
|
+
sortRec(root)
|
|
334
|
+
return root
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const countSessionTree = (node) => node.sessions.length + node.groups.reduce((sum, group) => sum + countSessionTree(group), 0)
|
|
338
|
+
|
|
339
|
+
const collectSessionRows = (node) => {
|
|
340
|
+
const out = node.sessions.slice()
|
|
341
|
+
for (const group of node.groups) out.push(...collectSessionRows(group))
|
|
342
|
+
return out
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const findSessionGroup = (node, path) => {
|
|
346
|
+
if (node.path === path) return node
|
|
347
|
+
for (const group of node.groups) {
|
|
348
|
+
const hit = findSessionGroup(group, path)
|
|
349
|
+
if (hit) return hit
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Build the folder tree. Folders are virtual: they exist where workspace
|
|
356
|
+
* titles contain "/", plus the explicit empty folders the user created.
|
|
357
|
+
* Returns { path, name, folders, workspaces } with workspaces carrying
|
|
358
|
+
* their leaf display name.
|
|
359
|
+
*/
|
|
360
|
+
function buildTree(items, explicitFolders) {
|
|
361
|
+
const root = { path: '', name: '', folders: [], workspaces: [] }
|
|
362
|
+
const byPath = new Map([['', root]])
|
|
363
|
+
const ensure = (path) => {
|
|
364
|
+
if (path === '') return root
|
|
365
|
+
const known = byPath.get(path)
|
|
366
|
+
if (known) return known
|
|
367
|
+
const segs = path.split('/')
|
|
368
|
+
const parent = ensure(segs.slice(0, -1).join('/'))
|
|
369
|
+
const node = { path, name: segs[segs.length - 1], folders: [], workspaces: [] }
|
|
370
|
+
byPath.set(path, node)
|
|
371
|
+
parent.folders.push(node)
|
|
372
|
+
return node
|
|
373
|
+
}
|
|
374
|
+
for (const folder of explicitFolders || []) {
|
|
375
|
+
const p = normPath(folder)
|
|
376
|
+
if (p !== '') ensure(p)
|
|
377
|
+
}
|
|
378
|
+
for (const workspace of items || []) {
|
|
379
|
+
const segs = splitTitleSegs(workspace.title)
|
|
380
|
+
const folderPath = segs.slice(0, -1).join('/')
|
|
381
|
+
const leaf = segs.length > 0 ? segs[segs.length - 1] : (basename(workspace.path) || String(workspace.title || '') || String(workspace.workspaceId || ''))
|
|
382
|
+
ensure(folderPath).workspaces.push({
|
|
383
|
+
workspaceId: workspace.workspaceId,
|
|
384
|
+
title: String(workspace.title || ''),
|
|
385
|
+
path: String(workspace.path || ''),
|
|
386
|
+
sessionIds: Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [],
|
|
387
|
+
leaf,
|
|
388
|
+
folderPath,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
const sortRec = (node) => {
|
|
392
|
+
node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
|
393
|
+
for (const child of node.folders) sortRec(child)
|
|
394
|
+
}
|
|
395
|
+
sortRec(root)
|
|
396
|
+
return root
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const countWorkspaces = (node) => (node.kind === 'ws' ? 1 : node.workspaces.length + node.folders.reduce((sum, f) => sum + countWorkspaces(f), 0))
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* VS Code-style single-child chain compression (preference-controlled):
|
|
403
|
+
* a folder level holding exactly ONE child and nothing else merges into a
|
|
404
|
+
* single display row — folder chains join names with "/" (keeps the DEEPEST
|
|
405
|
+
* path as its expansion identity), and a folder whose only child is one
|
|
406
|
+
* workspace becomes that workspace row with the merged label. Only
|
|
407
|
+
* presentation changes; the underlying workspace/title data is untouched.
|
|
408
|
+
*/
|
|
409
|
+
function compressTree(node) {
|
|
410
|
+
const folders = (node.folders || []).map(compressTree)
|
|
411
|
+
const workspaces = node.workspaces || []
|
|
412
|
+
if (workspaces.length === 0 && folders.length === 1) {
|
|
413
|
+
const child = folders[0]
|
|
414
|
+
if (child.kind === 'ws') {
|
|
415
|
+
const ws = child.workspace
|
|
416
|
+
return {
|
|
417
|
+
kind: 'ws',
|
|
418
|
+
path: child.path,
|
|
419
|
+
workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' },
|
|
420
|
+
folders: [],
|
|
421
|
+
workspaces: [],
|
|
422
|
+
dropPath: child.path,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { kind: 'folder', path: child.path, name: node.name + '/' + child.name, folders: child.folders || [], workspaces: child.workspaces || [], dropPath: child.path || node.path }
|
|
426
|
+
}
|
|
427
|
+
if (folders.length === 0 && workspaces.length === 1) {
|
|
428
|
+
const ws = workspaces[0]
|
|
429
|
+
return { kind: 'ws', path: node.path, workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' }, folders: [], workspaces: [], dropPath: node.path }
|
|
430
|
+
}
|
|
431
|
+
return { kind: 'folder', path: node.path, name: node.name, folders, workspaces, dropPath: node.path }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ============================== styles ============================ */
|
|
435
|
+
|
|
436
|
+
const CSS_TEXT = [
|
|
437
|
+
'.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
438
|
+
'.bw-header{display:flex;align-items:center;gap:2px;padding:10px 10px 4px;flex:none}',
|
|
439
|
+
'.bw-header-title{flex:1;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary,#b8b8b8);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
440
|
+
'.bw-icon-btn{flex:none;width:24px;height:24px;border:none;background:transparent;border-radius:6px;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
|
|
441
|
+
'.bw-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15));color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
442
|
+
'.bw-search-row{flex:none;padding:0 10px 6px}',
|
|
443
|
+
'.bw-input{width:100%;box-sizing:border-box;height:26px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;color:inherit;padding:0 8px;font-size:12px;outline:none;font-family:inherit}',
|
|
444
|
+
'.bw-input:focus{border-color:var(--dsw-alias-brand-primary,#5b8def)}',
|
|
445
|
+
'.bw-input::placeholder{color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
|
|
446
|
+
'.bw-tree{flex:1;overflow-y:auto;overflow-x:hidden;padding:2px 6px 12px;min-height:0}',
|
|
447
|
+
'.bw-row{display:flex;align-items:center;gap:6px;min-height:28px;padding:0 6px;border-radius:6px;cursor:pointer;user-select:none;font-size:13px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative}',
|
|
448
|
+
'.bw-drop-before::after{content:"";position:absolute;left:8px;right:8px;top:-1px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary,#5b8def);pointer-events:none}',
|
|
449
|
+
'.bw-drop-after::after{content:"";position:absolute;left:8px;right:8px;bottom:-1px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary,#5b8def);pointer-events:none}',
|
|
450
|
+
'.bw-drop-into{outline:1.5px dashed var(--dsw-alias-brand-primary,#5b8def);outline-offset:-1.5px}',
|
|
451
|
+
'.bw-row:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)))}',
|
|
452
|
+
'.bw-row:hover{background:color-mix(in srgb,var(--dsw-specific-sidebar-nav-item-hover,rgba(127,127,127,.14)) 50%,transparent)}',
|
|
453
|
+
'.bw-row-current{background:var(--dsw-specific-sidebar-nav-item-active,rgba(91,141,239,.15))}',
|
|
454
|
+
'.bw-row-current{background:color-mix(in srgb,var(--dsw-specific-sidebar-nav-item-active,rgba(91,141,239,.16)) 40%,transparent)}',
|
|
455
|
+
'.bw-row-icon{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
|
|
456
|
+
'.bw-chevron{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .15s ease}',
|
|
457
|
+
'.bw-chevron-open{transform:rotate(90deg)}',
|
|
458
|
+
'.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
459
|
+
'.bw-row-count{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
|
|
460
|
+
'.bw-row-time{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
|
|
461
|
+
'.bw-row-actions{flex:none;display:none;align-items:center;gap:2px}',
|
|
462
|
+
'.bw-row:hover .bw-row-actions{display:flex}',
|
|
463
|
+
'.bw-row:hover .bw-row-time,.bw-row:hover .bw-row-count{display:none}',
|
|
464
|
+
'.bw-dot{flex:none;width:6px;height:6px;border-radius:50%;background:transparent}',
|
|
465
|
+
'.bw-session-row{font-size:12.5px;color:var(--dsw-alias-label-secondary,#b8b8b8);min-height:26px}',
|
|
466
|
+
'.bw-sgroup-row{font-size:12.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a);min-height:24px}',
|
|
467
|
+
'.bw-sgroup-row:hover{color:var(--dsw-alias-label-secondary,#b8b8b8)}',
|
|
468
|
+
'.bw-session-row:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
469
|
+
'.bw-empty{padding:28px 12px;text-align:center;font-size:12px;color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
|
|
470
|
+
'.bw-swatch{width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));cursor:pointer;flex:none;background:transparent;padding:0}',
|
|
471
|
+
'.bw-swatch-active{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:1px}',
|
|
472
|
+
'.bw-color-input{width:36px;height:26px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;cursor:pointer;padding:0}',
|
|
473
|
+
'.bw-seg{display:flex;gap:6px;flex-wrap:wrap}',
|
|
474
|
+
'.bw-seg-btn{height:24px;padding:0 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);font-size:12px;cursor:pointer;font-family:inherit}',
|
|
475
|
+
'.bw-seg-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
476
|
+
'.bw-seg-btn-active{background:var(--dsw-alias-brand-primary,#5b8def);border-color:transparent;color:var(--dsw-alias-brand-text,#fff)}',
|
|
477
|
+
'.bw-seg-btn-active:hover{background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#5b8def));border-color:transparent}',
|
|
478
|
+
'.bw-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:4px}',
|
|
479
|
+
'.bw-icon-cell{height:30px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
|
|
480
|
+
'.bw-icon-cell:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
481
|
+
'.bw-icon-cell-active{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6);outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px}',
|
|
482
|
+
'.bw-icon-none{width:12px;height:2px;background:currentColor;border-radius:1px;opacity:.7}',
|
|
483
|
+
'.bw-rgb-row{display:flex;gap:10px;align-items:center}',
|
|
484
|
+
'.bw-rgb-label{display:flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
|
|
485
|
+
'.bw-rgb-input{width:52px;height:26px;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;color:inherit;font-size:12px;padding:0 6px;font-family:inherit}',
|
|
486
|
+
'.bw-ctx-overlay{position:fixed;inset:0;z-index:40}',
|
|
487
|
+
'.bw-ctx-menu{position:fixed;min-width:170px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay,rgba(28,28,32,.72)));-webkit-backdrop-filter:var(--dsh-any-blur-card-panels,blur(12px) saturate(1.15));backdrop-filter:var(--dsh-any-blur-card-panels,blur(12px) saturate(1.15));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.35);display:flex;flex-direction:column}',
|
|
488
|
+
'.bw-ctx-item{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12.5px;border-radius:6px;cursor:pointer;text-align:left;font-family:inherit}',
|
|
489
|
+
'.bw-ctx-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
|
|
490
|
+
'.bw-ctx-danger{color:var(--dsw-alias-state-error-primary,#f85149)}',
|
|
491
|
+
'.bw-ctx-sep{height:1px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.2));margin:4px 6px}',
|
|
492
|
+
'.bw-settings{display:flex;flex-direction:column;gap:6px;max-width:640px}',
|
|
493
|
+
'.bw-plugin-card{list-style:none;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));border-radius:12px;background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.05));transition:border-color .16s,background .16s}',
|
|
494
|
+
'.bw-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
|
|
495
|
+
'.bw-plugin-card-open{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border-color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
|
|
496
|
+
'.bw-plugin-head{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}',
|
|
497
|
+
'.bw-plugin-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px}',
|
|
498
|
+
'.bw-plugin-headtext{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}',
|
|
499
|
+
'.bw-plugin-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
500
|
+
'.bw-plugin-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
|
|
501
|
+
'.bw-plugin-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .16s}',
|
|
502
|
+
'.bw-plugin-chevron-open{transform:rotate(180deg)}',
|
|
503
|
+
'.bw-plugin-body{border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));margin:0 16px;padding:12px 0 16px}',
|
|
504
|
+
'.bw-setting-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
|
|
505
|
+
'.bw-setting-label{flex:1;min-width:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
|
|
506
|
+
'.bw-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:10px;background:var(--dsw-alias-border-l3,rgba(127,127,127,.3));cursor:pointer;transition:background 120ms ease}',
|
|
507
|
+
'.bw-switch:hover{background:var(--dsw-alias-label-dimmed,#7a7a7a)}',
|
|
508
|
+
'.bw-switch-on{background:var(--dsw-alias-brand-primary,#5b8def)}',
|
|
509
|
+
'.bw-switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:2px}',
|
|
510
|
+
'.bw-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground,#fff);transition:transform 120ms ease}',
|
|
511
|
+
'.bw-switch-on .bw-switch-thumb{transform:translateX(16px)}',
|
|
512
|
+
'.bw-plugin-body .bw-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
|
|
513
|
+
'.bw-rail{display:flex;flex-direction:column;align-items:center;gap:6px;padding:6px 0}',
|
|
514
|
+
'.bw-rail-btn{width:36px;height:36px;border:none;background:transparent;border-radius:8px;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
|
|
515
|
+
'.bw-rail-btn:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)));color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
516
|
+
'.bw-modal-body{display:flex;flex-direction:column;gap:10px;min-width:300px;max-width:380px;box-sizing:border-box}',
|
|
517
|
+
'.bw-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
|
|
518
|
+
'.bw-hint{font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a);line-height:1.5;white-space:normal;word-break:break-word}',
|
|
519
|
+
'.bw-path-echo{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a);word-break:break-all;max-width:380px}',
|
|
520
|
+
'.bw-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
|
|
521
|
+
'.bw-btn{height:28px;padding:0 14px;border-radius:6px;font-size:12.5px;cursor:pointer;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));background:transparent;color:var(--dsw-alias-label-primary,#e6e6e6);font-family:inherit}',
|
|
522
|
+
'.bw-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
523
|
+
'.bw-btn-primary{background:var(--dsw-alias-brand-primary,#5b8def);border-color:transparent;color:var(--dsw-alias-brand-text,#fff)}',
|
|
524
|
+
'.bw-btn-primary:hover{background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#5b8def))}',
|
|
525
|
+
'.bw-btn:disabled{opacity:.5;cursor:default}',
|
|
526
|
+
'.bw-error-text{font-size:12.5px;color:var(--dsw-alias-state-error-primary,#f85149);word-break:break-all;max-width:380px}',
|
|
527
|
+
'.bw-dialog-input-row{display:flex;gap:6px;align-items:center}',
|
|
528
|
+
'.bw-glow-row{display:flex;align-items:center;gap:10px}',
|
|
529
|
+
'.bw-slider{flex:1;accent-color:var(--dsw-alias-brand-primary,#5b8def);height:22px}',
|
|
530
|
+
'.bw-glow-value{min-width:44px;text-align:right;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8);font-variant-numeric:tabular-nums}',
|
|
531
|
+
'.bw-preview{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px dashed var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:6px;min-height:28px}',
|
|
532
|
+
'.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
533
|
+
'.bw-preview-icon svg{width:18px;height:18px}',
|
|
534
|
+
'.bw-preview-label{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:18px}',
|
|
535
|
+
].join('')
|
|
536
|
+
|
|
537
|
+
const StyleNode = () => E('style', null, CSS_TEXT)
|
|
538
|
+
|
|
539
|
+
/* ========================== view store =========================== */
|
|
540
|
+
|
|
541
|
+
const createViewStore = () => storeKit.defineStore({
|
|
542
|
+
init: () => ({ folders: [], expanded: {}, sessionsExpanded: {}, sessionGroups: {}, prefs: { compactChains: true }, styling: {} }),
|
|
543
|
+
// NOTE: hydration REPLACES the state with the persisted whole value —
|
|
544
|
+
// init defaults never merge. Every action must tolerate a missing key
|
|
545
|
+
// (states persisted by older plugin versions lack sessionGroups), and
|
|
546
|
+
// every selector read takes a fallback.
|
|
547
|
+
persist: 'dsh.betterWorkspace.view.v1',
|
|
548
|
+
actions: {
|
|
549
|
+
setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
|
|
550
|
+
setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
|
|
551
|
+
setSessionGroupExpanded: (d, key, value) => { if (!d.sessionGroups) d.sessionGroups = {}; d.sessionGroups[key] = value },
|
|
552
|
+
setPref: (d, key, value) => { if (!d.prefs) d.prefs = {}; d.prefs[key] = value },
|
|
553
|
+
setStyling: (d, key, value) => { if (!d.styling) d.styling = {}; if (value === null) delete d.styling[key]; else d.styling[key] = value },
|
|
554
|
+
addFolder: (d, path) => {
|
|
555
|
+
if (!Array.isArray(d.folders)) d.folders = []
|
|
556
|
+
const p = normPath(path)
|
|
557
|
+
if (p !== '' && !d.folders.includes(p)) d.folders.push(p)
|
|
558
|
+
},
|
|
559
|
+
removeFolder: (d, path) => {
|
|
560
|
+
if (!Array.isArray(d.folders)) d.folders = []
|
|
561
|
+
d.folders = d.folders.filter(f => f !== path)
|
|
562
|
+
},
|
|
563
|
+
renameFolder: (d, oldPath, newPath) => {
|
|
564
|
+
if (!Array.isArray(d.folders)) d.folders = []
|
|
565
|
+
const oo = oldPath + '/'
|
|
566
|
+
const nn = newPath + '/'
|
|
567
|
+
const next = d.folders.map(f => (f === oldPath ? newPath : (f.startsWith(oo) ? nn + f.slice(oo.length) : f)))
|
|
568
|
+
d.folders = Array.from(new Set(next))
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
/* ============================ flow dialog ========================= */
|
|
574
|
+
|
|
575
|
+
const BTN = (props) => (
|
|
576
|
+
ui.Button
|
|
577
|
+
? E(ui.Button, props)
|
|
578
|
+
: E('button', { type: 'button', className: cls('bw-btn', props.variant === 'primary' && 'bw-btn-primary'), onClick: props.onClick, disabled: props.disabled }, props.children)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
function TextDialog({ title, hint, initial, confirmLabel, onConfirm, onClose, t }) {
|
|
582
|
+
const [value, setValue] = React.useState(initial)
|
|
583
|
+
const inputRef = React.useRef(null)
|
|
584
|
+
React.useEffect(() => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [])
|
|
585
|
+
const commit = () => onConfirm(value)
|
|
586
|
+
return E(ui.Modal, {
|
|
587
|
+
open: true,
|
|
588
|
+
onClose,
|
|
589
|
+
closeLabel: t('close'),
|
|
590
|
+
title,
|
|
591
|
+
footer: E('div', { className: 'bw-modal-actions' },
|
|
592
|
+
E(BTN, { variant: 'outline', onClick: onClose }, t('cancel')),
|
|
593
|
+
E(BTN, { variant: 'primary', onClick: commit }, confirmLabel || t('confirm')),
|
|
594
|
+
),
|
|
595
|
+
},
|
|
596
|
+
E('div', { className: 'bw-modal-body' },
|
|
597
|
+
E('div', { className: 'bw-field' },
|
|
598
|
+
E('input', {
|
|
599
|
+
ref: inputRef,
|
|
600
|
+
className: 'bw-input',
|
|
601
|
+
value,
|
|
602
|
+
onChange: (e) => setValue(e.target.value),
|
|
603
|
+
onKeyDown: (e) => { if (e.key === 'Enter') commit() },
|
|
604
|
+
}),
|
|
605
|
+
hint ? E('div', { className: 'bw-hint' }, hint) : null,
|
|
606
|
+
),
|
|
607
|
+
),
|
|
608
|
+
StyleNode(),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function ConfirmDialog({ title, body, onConfirm, onClose, t }) {
|
|
613
|
+
return E(ui.Modal, {
|
|
614
|
+
open: true,
|
|
615
|
+
onClose,
|
|
616
|
+
closeLabel: t('close'),
|
|
617
|
+
title,
|
|
618
|
+
footer: E('div', { className: 'bw-modal-actions' },
|
|
619
|
+
E(BTN, { variant: 'outline', onClick: onClose }, t('cancel')),
|
|
620
|
+
E(BTN, { variant: 'primary', onClick: onConfirm }, t('confirm')),
|
|
621
|
+
),
|
|
622
|
+
}, E('div', { className: 'bw-modal-body' }, E('div', { className: 'bw-hint' }, body)), StyleNode())
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Render crash insurance: dsh boots all-or-nothing (one failed entry
|
|
627
|
+
* fails the whole web boot) and React unmounts the root on an uncaught
|
|
628
|
+
* render error — so our registrations render behind this boundary and a
|
|
629
|
+
* bug degrades to "region renders nothing", never a blank application.
|
|
630
|
+
*/
|
|
631
|
+
class QuietBoundary extends React.Component {
|
|
632
|
+
constructor(props) {
|
|
633
|
+
super(props)
|
|
634
|
+
this.state = { failed: false }
|
|
635
|
+
}
|
|
636
|
+
static getDerivedStateFromError() {
|
|
637
|
+
return { failed: true }
|
|
638
|
+
}
|
|
639
|
+
componentDidCatch(error, info) {
|
|
640
|
+
console.error('[dsh-better-workspace] render error (region degraded to empty)', error, info)
|
|
641
|
+
}
|
|
642
|
+
render() {
|
|
643
|
+
return this.state.failed ? null : this.props.children
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The add-workspace picking interaction: native directory pick, then a
|
|
649
|
+
* small parent-group popup, then create + rename with the chosen prefix.
|
|
650
|
+
* Works as a directoryFlow occupant (owner conversation props) and as the
|
|
651
|
+
* browser's directly composed flow (same props, owner state lives above).
|
|
652
|
+
*/
|
|
653
|
+
function BetterFlow(props) {
|
|
654
|
+
const { open, busy, onPicked, onCancel, onError, createWorkspace, renameWorkspace, pickDirectory, useWorkspaces, useStore, t } = props
|
|
655
|
+
const actions = props.actions
|
|
656
|
+
const [phase, setPhase] = React.useState('idle') // idle | picking | picked | submitting
|
|
657
|
+
const [pickedPath, setPickedPath] = React.useState('')
|
|
658
|
+
const [parentInput, setParentInput] = React.useState('')
|
|
659
|
+
// All hooks run before any early return: the flow unmounts its dialog
|
|
660
|
+
// while closed, but its hook sequence must stay stable.
|
|
661
|
+
const snapshotItems = typeof useWorkspaces === 'function' ? useWorkspaces(s => s.items) : []
|
|
662
|
+
const storeFolders = typeof useStore === 'function' ? (useStore(s => s.folders) || []) : []
|
|
663
|
+
|
|
664
|
+
React.useEffect(() => {
|
|
665
|
+
if (!open) {
|
|
666
|
+
setPhase('idle')
|
|
667
|
+
setPickedPath('')
|
|
668
|
+
setParentInput('')
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
let alive = true
|
|
672
|
+
setPhase('picking')
|
|
673
|
+
Promise.resolve()
|
|
674
|
+
.then(() => pickDirectory())
|
|
675
|
+
.then((path) => {
|
|
676
|
+
if (!alive) return
|
|
677
|
+
if (!path) { onCancel(); return }
|
|
678
|
+
setPickedPath(String(path))
|
|
679
|
+
setParentInput('')
|
|
680
|
+
setPhase('picked')
|
|
681
|
+
})
|
|
682
|
+
.catch((reason) => {
|
|
683
|
+
if (!alive) return
|
|
684
|
+
setPhase('idle')
|
|
685
|
+
onError(messageOf(reason))
|
|
686
|
+
})
|
|
687
|
+
return () => { alive = false }
|
|
688
|
+
}, [open])
|
|
689
|
+
|
|
690
|
+
if (!open || (phase !== 'picked' && phase !== 'submitting')) return null
|
|
691
|
+
|
|
692
|
+
const folderOptions = (() => {
|
|
693
|
+
const set = new Set(storeFolders)
|
|
694
|
+
for (const w of snapshotItems || []) {
|
|
695
|
+
const segs = splitTitleSegs(w.title)
|
|
696
|
+
for (let i = 1; i < segs.length; i++) set.add(segs.slice(0, i).join('/'))
|
|
697
|
+
}
|
|
698
|
+
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh'))
|
|
699
|
+
})()
|
|
700
|
+
|
|
701
|
+
const confirm = () => {
|
|
702
|
+
if (phase === 'submitting') return
|
|
703
|
+
const prefix = normPath(parentInput)
|
|
704
|
+
const base = basename(pickedPath)
|
|
705
|
+
const fullTitle = prefix !== '' ? prefix + '/' + base : base
|
|
706
|
+
setPhase('submitting')
|
|
707
|
+
Promise.resolve()
|
|
708
|
+
.then(() => createWorkspace({ path: pickedPath }))
|
|
709
|
+
.then(async (workspace) => {
|
|
710
|
+
try {
|
|
711
|
+
await renameWorkspace(workspace.workspaceId, fullTitle)
|
|
712
|
+
} catch (renameError) {
|
|
713
|
+
onError(messageOf(renameError))
|
|
714
|
+
onCancel()
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
if (prefix !== '' && actions && typeof actions.addFolder === 'function') actions.addFolder(prefix)
|
|
718
|
+
onCancel()
|
|
719
|
+
})
|
|
720
|
+
.catch((reason) => {
|
|
721
|
+
onError(messageOf(reason))
|
|
722
|
+
onCancel()
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const submitting = phase === 'submitting' || busy === true
|
|
727
|
+
const datalistId = 'bw-folder-options'
|
|
728
|
+
const body = E('div', { className: 'bw-modal-body' },
|
|
729
|
+
E('div', { className: 'bw-field' },
|
|
730
|
+
t('flow.picked'),
|
|
731
|
+
E('div', { className: 'bw-path-echo' }, pickedPath),
|
|
732
|
+
),
|
|
733
|
+
E('div', { className: 'bw-field' },
|
|
734
|
+
t('flow.parent'),
|
|
735
|
+
E('div', { className: 'bw-dialog-input-row' },
|
|
736
|
+
E('input', {
|
|
737
|
+
className: 'bw-input',
|
|
738
|
+
list: datalistId,
|
|
739
|
+
value: parentInput,
|
|
740
|
+
autoFocus: true,
|
|
741
|
+
placeholder: 'web/frontend',
|
|
742
|
+
disabled: submitting,
|
|
743
|
+
onChange: (e) => setParentInput(e.target.value),
|
|
744
|
+
onKeyDown: (e) => { if (e.key === 'Enter') confirm() },
|
|
745
|
+
}),
|
|
746
|
+
E('datalist', { id: datalistId },
|
|
747
|
+
folderOptions.map((option) => E('option', { key: option, value: option })),
|
|
748
|
+
),
|
|
749
|
+
),
|
|
750
|
+
E('div', { className: 'bw-hint' }, t('flow.parentHint')),
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
const footer = E('div', { className: 'bw-modal-actions' },
|
|
754
|
+
E(BTN, { variant: 'outline', onClick: onCancel, disabled: submitting }, t('cancel')),
|
|
755
|
+
E(BTN, { variant: 'primary', onClick: confirm, disabled: submitting }, submitting ? t('flow.creating') : t('create')),
|
|
756
|
+
)
|
|
757
|
+
return E(ui.Modal, { open: true, onClose: () => { if (!submitting) onCancel() }, closeLabel: t('close'), title: t('flow.title'), footer }, body, StyleNode())
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/* ============================== rows ============================== */
|
|
761
|
+
|
|
762
|
+
/** Folder glyph variants from the primitives family (solid / outline / hidden). */
|
|
763
|
+
/** Custom icon value → glyph: legacy slots (solid/outline/none) or any primitives icon name. */
|
|
764
|
+
const iconOf = (mode, expanded) => {
|
|
765
|
+
if (!mode || mode === 'none') return null
|
|
766
|
+
if (mode === 'solid') return icon(expanded ? 'IconFolderOpen16' : 'IconFolderClose16')
|
|
767
|
+
if (mode === 'outline') return icon('IconFolderOpenOutline16')
|
|
768
|
+
return icon(mode)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const colorToRgb = (hex) => {
|
|
772
|
+
if (!hex) return null
|
|
773
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex)
|
|
774
|
+
if (!m) return null
|
|
775
|
+
const v = parseInt(m[1], 16)
|
|
776
|
+
return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
|
|
777
|
+
}
|
|
778
|
+
const rgbToHex = (r, g, b) => '#' + [r, g, b]
|
|
779
|
+
.map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
|
|
780
|
+
.join('')
|
|
781
|
+
|
|
782
|
+
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, t }) {
|
|
783
|
+
const total = countWorkspaces(node)
|
|
784
|
+
return E('div', {
|
|
785
|
+
className: cls('bw-row', dropInto && 'bw-drop-into'),
|
|
786
|
+
style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
|
|
787
|
+
onClick: onToggle,
|
|
788
|
+
onContextMenu: onContextMenu,
|
|
789
|
+
role: 'treeitem',
|
|
790
|
+
'aria-expanded': expanded,
|
|
791
|
+
...(dragEvents || {}),
|
|
792
|
+
},
|
|
793
|
+
E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
794
|
+
E('span', { className: 'bw-row-icon' }, iconOf(iconMode, expanded)),
|
|
795
|
+
E('span', { className: 'bw-row-label' }, node.name),
|
|
796
|
+
total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, t }) {
|
|
801
|
+
return E('div', {
|
|
802
|
+
className: cls('bw-row', currentInside && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
|
|
803
|
+
style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}) },
|
|
804
|
+
onClick: onToggle,
|
|
805
|
+
onContextMenu: onContextMenu,
|
|
806
|
+
role: 'treeitem',
|
|
807
|
+
'aria-expanded': sessionsOpen,
|
|
808
|
+
...(dragEvents || {}),
|
|
809
|
+
},
|
|
810
|
+
E('span', { className: cls('bw-chevron', sessionsOpen && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
811
|
+
E('span', { className: 'bw-row-icon' }, iconOf(iconMode, sessionsOpen)),
|
|
812
|
+
E('span', { className: 'bw-row-label', title: workspace.title || workspace.leaf }, workspace.leaf),
|
|
813
|
+
count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
|
|
814
|
+
E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
|
|
815
|
+
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, t }) {
|
|
821
|
+
// Official status priority: pending interaction > running > running
|
|
822
|
+
// subagents > completed reminder; idle rows show no dot at all.
|
|
823
|
+
let status = null
|
|
824
|
+
if (node.pending === 'approval' || node.pending === 'plan-review' || node.pending === 'question') {
|
|
825
|
+
status = { state: 'warning', title: t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending)) }
|
|
826
|
+
} else if (node.running || node.subagents > 0) {
|
|
827
|
+
status = { state: 'ongoing', title: node.running ? t('status.running') : t('status.subagents', { n: node.subagents }) }
|
|
828
|
+
} else if (node.completed) {
|
|
829
|
+
status = { state: 'done', title: t('status.completed') }
|
|
830
|
+
}
|
|
831
|
+
return E('div', {
|
|
832
|
+
className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
|
|
833
|
+
style: { paddingLeft: 8 + depth * 12, ...(custStyle || {}) },
|
|
834
|
+
onClick: () => onOpen(node.id),
|
|
835
|
+
onContextMenu: onContextMenu,
|
|
836
|
+
role: 'treeitem',
|
|
837
|
+
...(dragEvents || {}),
|
|
838
|
+
},
|
|
839
|
+
E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
|
|
840
|
+
status && typeof ui.StateDot === 'function'
|
|
841
|
+
? E(ui.StateDot, { state: status.state, size: 10 })
|
|
842
|
+
: E('span', { className: 'bw-dot' }),
|
|
843
|
+
),
|
|
844
|
+
E('span', { className: 'bw-row-label' }, node.leaf || node.title),
|
|
845
|
+
E('span', { className: 'bw-row-time' }, timeLabel(node.updatedAt, now, t)),
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Session sub-group inside a workspace (same "/" convention on session
|
|
851
|
+
* titles). Deliberately NOT styled like a workspace folder — no folder
|
|
852
|
+
* icon, tertiary color — so a session level never reads as a workspace.
|
|
853
|
+
*/
|
|
854
|
+
function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, t }) {
|
|
855
|
+
return E('div', {
|
|
856
|
+
className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
|
|
857
|
+
style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
|
|
858
|
+
onClick: onToggle,
|
|
859
|
+
onContextMenu: onContextMenu,
|
|
860
|
+
role: 'treeitem',
|
|
861
|
+
'aria-expanded': expanded,
|
|
862
|
+
...(dragEvents || {}),
|
|
863
|
+
},
|
|
864
|
+
E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
865
|
+
E('span', { className: 'bw-row-label' }, name),
|
|
866
|
+
count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/* --------------------- customization dialog ----------------------- */
|
|
871
|
+
|
|
872
|
+
const SWATCHES = ['', '#5b8def', '#3fb950', '#d29922', '#f85149', '#a371f7', '#39c5cf', '#ec6cb9', '#ff9f45', '#6e7681']
|
|
873
|
+
const GLOW_MAX = 14
|
|
874
|
+
const ICON_CHOICES = [
|
|
875
|
+
'solid', 'outline', 'none',
|
|
876
|
+
'IconProjectAddOutline16', 'IconBranchOutline16', 'IconArchiveOutline20', 'IconCodeOutline16',
|
|
877
|
+
'IconDataOutline16', 'IconGoalOutline16', 'IconGlobeOutline14', 'IconInspectOutline12',
|
|
878
|
+
'IconCopyOutline16', 'IconLinkOutline16', 'IconListPenOutline16', 'IconChecklistOutline14',
|
|
879
|
+
'IconBrowseOutline16', 'IconDownloadOutline16', 'IconContextInjectionOutline16',
|
|
880
|
+
'IconCordisPluginOutline14', 'IconApiOutline14', 'IconAgentPresetOutline16', 'IconEnhanceOutline16',
|
|
881
|
+
'IconSkillOutline16', 'IconSparkle16', 'IconNewChatOutline16',
|
|
882
|
+
'IconCheckOutline14', 'IconCheckOutline16', 'IconChevronDownOutline14', 'IconChevronLeftOutline14',
|
|
883
|
+
'IconChevronRightOutline14', 'IconChevronUpOutline14', 'IconCloseOutline16', 'IconDarkOutline16',
|
|
884
|
+
'IconEditOutline16', 'IconEllipsisOutline16', 'IconFolderClose16', 'IconFolderOpen16',
|
|
885
|
+
'IconFolderOpenOutline16', 'IconFullscreenOutline16', 'IconLightOutline16', 'IconLinkOutline14',
|
|
886
|
+
'IconLoadingOutline16', 'IconPanelLeftOutline16', 'IconPaperclipOutline16', 'IconPersonalizationOutline16',
|
|
887
|
+
'IconPlayOutline16', 'IconPauseOutline16', 'IconPlusOutline16', 'IconQuestionOutline14',
|
|
888
|
+
'IconQueueOutline14', 'IconRefreshOutline14', 'IconRefreshOutline16', 'IconRightUpOutline14',
|
|
889
|
+
'IconSearchOutline16', 'IconSendOutline14', 'IconSendOutline16', 'IconSettingsOutline14',
|
|
890
|
+
'IconSettingsOutline16', 'IconShareOutline16', 'IconStopFill16', 'IconThinkOutline14',
|
|
891
|
+
'IconThinkOutline16', 'IconTrashOutline16', 'IconUserOutline16', 'IconWarningOutline16',
|
|
892
|
+
'IconLikeOutline16', 'IconDislikeOutline16', 'IconFollowsystemOutline16',
|
|
893
|
+
]
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Per-row appearance: color swatches (+ native picker), glow intensity,
|
|
897
|
+
* folder-glyph mode. Committing the defaults clears the entry; Reset
|
|
898
|
+
* removes it entirely.
|
|
899
|
+
*/
|
|
900
|
+
function CustomizeDialog({ open, initial, kind, onChange, onReset, onClose, t }) {
|
|
901
|
+
// Icons render only for workspace / workspace-folder rows. Session rows
|
|
902
|
+
// already carry the official status dot (pending/running/done) in the
|
|
903
|
+
// leading slot, and session sub-group rows have no icon either — so the
|
|
904
|
+
// icon grid is offered only where it actually displays.
|
|
905
|
+
const allowIcon = kind === 'folder' || kind === 'workspace'
|
|
906
|
+
const [color, setColor] = React.useState('')
|
|
907
|
+
const [glow, setGlow] = React.useState(0)
|
|
908
|
+
const [iconMode, setIconMode] = React.useState('solid')
|
|
909
|
+
const [weight, setWeight] = React.useState(0)
|
|
910
|
+
const [shadow, setShadow] = React.useState(false)
|
|
911
|
+
React.useEffect(() => {
|
|
912
|
+
if (!open) return
|
|
913
|
+
setColor(initial && initial.color ? initial.color : '')
|
|
914
|
+
setGlow(initial && initial.glow ? Number(initial.glow) || 0 : 0)
|
|
915
|
+
setIconMode(initial && initial.icon ? initial.icon : 'solid')
|
|
916
|
+
setWeight(initial && initial.weight ? Number(initial.weight) || 0 : 0)
|
|
917
|
+
setShadow(initial && initial.shadow === true)
|
|
918
|
+
}, [open, initial])
|
|
919
|
+
if (!open) return null
|
|
920
|
+
const commit = () => {
|
|
921
|
+
const empty = allowIcon
|
|
922
|
+
? (color === '' && glow === 0 && iconMode === 'solid' && weight === 0 && !shadow)
|
|
923
|
+
: (color === '' && glow === 0 && weight === 0 && !shadow)
|
|
924
|
+
const style = empty
|
|
925
|
+
? null
|
|
926
|
+
: { color, glow, weight: weight > 0 ? weight : undefined, shadow: shadow || undefined, ...(allowIcon ? { icon: iconMode } : {}) }
|
|
927
|
+
onChange(style)
|
|
928
|
+
onClose()
|
|
929
|
+
}
|
|
930
|
+
const channels = colorToRgb(color)
|
|
931
|
+
const setChannel = (index, raw) => {
|
|
932
|
+
const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
|
|
933
|
+
const base = channels || [0, 0, 0]
|
|
934
|
+
const next = base.slice()
|
|
935
|
+
next[index] = n
|
|
936
|
+
setColor(rgbToHex(next[0], next[1], next[2]))
|
|
937
|
+
}
|
|
938
|
+
const previewShadows = []
|
|
939
|
+
if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
|
|
940
|
+
if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
|
|
941
|
+
return E(ui.Modal, {
|
|
942
|
+
open: true,
|
|
943
|
+
onClose,
|
|
944
|
+
closeLabel: t('close'),
|
|
945
|
+
title: t('custom.title'),
|
|
946
|
+
footer: E('div', { className: 'bw-modal-actions' },
|
|
947
|
+
E(BTN, { variant: 'outline', onClick: () => { onReset(); onClose() } }, t('custom.reset')),
|
|
948
|
+
E(BTN, { variant: 'primary', onClick: commit }, t('custom.done')),
|
|
949
|
+
),
|
|
950
|
+
},
|
|
951
|
+
E('div', { className: 'bw-modal-body' },
|
|
952
|
+
E('div', { className: 'bw-field' },
|
|
953
|
+
t('custom.color'),
|
|
954
|
+
E('div', { className: 'bw-dialog-input-row' },
|
|
955
|
+
SWATCHES.map((swatch) => E('button', {
|
|
956
|
+
key: swatch || 'none',
|
|
957
|
+
type: 'button',
|
|
958
|
+
className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
|
|
959
|
+
style: swatch === '' ? undefined : { background: swatch },
|
|
960
|
+
'aria-label': swatch === '' ? t('custom.reset') : swatch,
|
|
961
|
+
onClick: () => setColor(swatch),
|
|
962
|
+
})),
|
|
963
|
+
E('input', {
|
|
964
|
+
type: 'color',
|
|
965
|
+
className: 'bw-color-input',
|
|
966
|
+
value: color || '#5b8def',
|
|
967
|
+
onChange: (e) => setColor(e.target.value),
|
|
968
|
+
}),
|
|
969
|
+
),
|
|
970
|
+
E('div', { className: 'bw-rgb-row' },
|
|
971
|
+
['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
|
|
972
|
+
label,
|
|
973
|
+
E('input', {
|
|
974
|
+
type: 'number',
|
|
975
|
+
className: 'bw-rgb-input',
|
|
976
|
+
min: 0,
|
|
977
|
+
max: 255,
|
|
978
|
+
value: channels ? channels[index] : '',
|
|
979
|
+
placeholder: '—',
|
|
980
|
+
onChange: (e) => setChannel(index, e.target.value),
|
|
981
|
+
}),
|
|
982
|
+
)),
|
|
983
|
+
),
|
|
984
|
+
),
|
|
985
|
+
E('div', { className: 'bw-field' },
|
|
986
|
+
t('custom.glow'),
|
|
987
|
+
E('div', { className: 'bw-glow-row' },
|
|
988
|
+
E('input', {
|
|
989
|
+
type: 'range',
|
|
990
|
+
className: 'bw-slider',
|
|
991
|
+
min: 0,
|
|
992
|
+
max: GLOW_MAX,
|
|
993
|
+
step: 1,
|
|
994
|
+
value: glow,
|
|
995
|
+
'aria-label': t('custom.glow'),
|
|
996
|
+
onChange: (e) => setGlow(Number(e.target.value)),
|
|
997
|
+
}),
|
|
998
|
+
E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
|
|
999
|
+
),
|
|
1000
|
+
),
|
|
1001
|
+
E('div', { className: 'bw-field' },
|
|
1002
|
+
t('custom.weight'),
|
|
1003
|
+
E('div', { className: 'bw-seg' },
|
|
1004
|
+
[400, 500, 600, 700].map((wt) => E('button', {
|
|
1005
|
+
key: String(wt),
|
|
1006
|
+
type: 'button',
|
|
1007
|
+
className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
|
|
1008
|
+
onClick: () => setWeight(wt),
|
|
1009
|
+
}, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
|
|
1010
|
+
),
|
|
1011
|
+
),
|
|
1012
|
+
E('div', { className: 'bw-field' },
|
|
1013
|
+
t('custom.shadow'),
|
|
1014
|
+
E('div', { className: 'bw-seg' },
|
|
1015
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => setShadow(false) }, t('custom.none')),
|
|
1016
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => setShadow(true) }, t('settings.on')),
|
|
1017
|
+
),
|
|
1018
|
+
),
|
|
1019
|
+
allowIcon ? E('div', { className: 'bw-field' },
|
|
1020
|
+
t('custom.icon'),
|
|
1021
|
+
E('div', { className: 'bw-icon-grid' },
|
|
1022
|
+
ICON_CHOICES.map((mode) => E('button', {
|
|
1023
|
+
key: mode,
|
|
1024
|
+
type: 'button',
|
|
1025
|
+
className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
|
|
1026
|
+
title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1027
|
+
'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1028
|
+
onClick: () => setIconMode(mode),
|
|
1029
|
+
}, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
|
|
1030
|
+
),
|
|
1031
|
+
) : null,
|
|
1032
|
+
E('div', { className: 'bw-field' },
|
|
1033
|
+
t('custom.preview'),
|
|
1034
|
+
E('div', {
|
|
1035
|
+
className: 'bw-preview',
|
|
1036
|
+
style: {
|
|
1037
|
+
color: color || undefined,
|
|
1038
|
+
fontWeight: weight > 0 ? weight : undefined,
|
|
1039
|
+
textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
|
|
1043
|
+
E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
|
|
1044
|
+
),
|
|
1045
|
+
),
|
|
1046
|
+
),
|
|
1047
|
+
StyleNode(),
|
|
1048
|
+
)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* ------------------------- settings page ------------------------- */
|
|
1052
|
+
|
|
1053
|
+
function BetterWorkspaceSettings({ useStore, actions, t }) {
|
|
1054
|
+
const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1055
|
+
const compactChains = prefs.compactChains !== false
|
|
1056
|
+
return E('div', { className: 'bw-settings' },
|
|
1057
|
+
StyleNode(),
|
|
1058
|
+
E('div', { className: 'bw-setting-row' },
|
|
1059
|
+
E('div', { className: 'bw-setting-label' }, t('settings.compactChains')),
|
|
1060
|
+
E('button', {
|
|
1061
|
+
type: 'button',
|
|
1062
|
+
role: 'switch',
|
|
1063
|
+
'aria-checked': compactChains,
|
|
1064
|
+
'aria-label': t('settings.compactChains'),
|
|
1065
|
+
className: cls('bw-switch', compactChains && 'bw-switch-on'),
|
|
1066
|
+
onClick: () => { actions.setPref('compactChains', !compactChains) },
|
|
1067
|
+
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1068
|
+
),
|
|
1069
|
+
E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
|
|
1070
|
+
)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/* --------- settings → plug-ins card (accordion like official cards) --------- */
|
|
1074
|
+
|
|
1075
|
+
function BetterWorkspacePluginCard({ useStore, actions, t }) {
|
|
1076
|
+
const [open, setOpen] = React.useState(false)
|
|
1077
|
+
const Chevron = ui.IconChevronDownOutline14
|
|
1078
|
+
return E('li', { className: cls('bw-plugin-card', open && 'bw-plugin-card-open') },
|
|
1079
|
+
E('button', {
|
|
1080
|
+
type: 'button',
|
|
1081
|
+
className: 'bw-plugin-head',
|
|
1082
|
+
'aria-expanded': open,
|
|
1083
|
+
'aria-label': (open ? t('settings.collapse') : t('settings.expand')) + ': ' + t('settings.title'),
|
|
1084
|
+
onClick: () => setOpen(!open),
|
|
1085
|
+
},
|
|
1086
|
+
E('span', { className: 'bw-plugin-headtext' },
|
|
1087
|
+
E('span', { className: 'bw-plugin-name' }, t('settings.title')),
|
|
1088
|
+
E('span', { className: 'bw-plugin-desc' }, t('settings.desc')),
|
|
1089
|
+
),
|
|
1090
|
+
Chevron ? E(Chevron, { className: cls('bw-plugin-chevron', open && 'bw-plugin-chevron-open') }) : null,
|
|
1091
|
+
),
|
|
1092
|
+
open ? E('div', { className: 'bw-plugin-body' },
|
|
1093
|
+
E(BetterWorkspaceSettings, { useStore, actions, t }),
|
|
1094
|
+
) : null,
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* ============================= browser ============================ */
|
|
1099
|
+
|
|
1100
|
+
function BetterBrowser(props) {
|
|
1101
|
+
const {
|
|
1102
|
+
wide, expandSidebar,
|
|
1103
|
+
useSessions, useSessionPendingInteraction, useWorkspaces,
|
|
1104
|
+
useStore, actions,
|
|
1105
|
+
startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace,
|
|
1106
|
+
archiveSession, createWorkspace, pickDirectory, insertWorkspaceBefore, insertSessionBefore,
|
|
1107
|
+
t,
|
|
1108
|
+
} = props
|
|
1109
|
+
|
|
1110
|
+
if (typeof useWorkspaces !== 'function' || typeof useSessions !== 'function') {
|
|
1111
|
+
console.error('[dsh-better-workspace] standard snapshot hooks missing; browser renders nothing')
|
|
1112
|
+
return null
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const items = useWorkspaces(s => s.items)
|
|
1116
|
+
const phase = useWorkspaces(s => s.phase)
|
|
1117
|
+
const archivedSessionIds = useWorkspaces(s => s.archivedSessionIds) || []
|
|
1118
|
+
const list = useSessions(s => s)
|
|
1119
|
+
const pending = useSessionPendingInteraction ? useSessionPendingInteraction(s => s) : null
|
|
1120
|
+
const storeFolders = useStore ? (useStore(s => s.folders) || []) : []
|
|
1121
|
+
const expandedMap = useStore ? (useStore(s => s.expanded) || {}) : {}
|
|
1122
|
+
const sessionsExpandedMap = useStore ? (useStore(s => s.sessionsExpanded) || {}) : {}
|
|
1123
|
+
const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
|
|
1124
|
+
const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1125
|
+
const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
|
|
1126
|
+
const compactChains = prefsMap.compactChains !== false
|
|
1127
|
+
const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
|
|
1128
|
+
const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
|
|
1129
|
+
|
|
1130
|
+
const [query, setQuery] = React.useState('')
|
|
1131
|
+
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
1132
|
+
const [flowOpen, setFlowOpen] = React.useState(false)
|
|
1133
|
+
const [dialog, setDialog] = React.useState(null) // { kind, ... }
|
|
1134
|
+
const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
|
|
1135
|
+
const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
|
|
1136
|
+
const [errorText, setErrorText] = React.useState(null)
|
|
1137
|
+
const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
|
|
1138
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
1139
|
+
const now = Date.now()
|
|
1140
|
+
|
|
1141
|
+
const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
|
|
1142
|
+
|
|
1143
|
+
const styleEntry = (key) => stylingMap[key] || null
|
|
1144
|
+
const rowStyleOf = (key) => {
|
|
1145
|
+
const entry = styleEntry(key)
|
|
1146
|
+
if (!entry || !entry.color) return null
|
|
1147
|
+
const glow = Number(entry.glow) || 0
|
|
1148
|
+
const weight = Number(entry.weight) || 0
|
|
1149
|
+
const shadow = entry.shadow === true
|
|
1150
|
+
const shadows = []
|
|
1151
|
+
if (glow > 0) shadows.push('0 0 ' + glow + 'px ' + entry.color)
|
|
1152
|
+
if (shadow) shadows.push('1px 1px 2px rgba(0,0,0,.85)')
|
|
1153
|
+
return {
|
|
1154
|
+
color: entry.color,
|
|
1155
|
+
fontWeight: weight > 0 ? weight : undefined,
|
|
1156
|
+
textShadow: shadows.length > 0 ? shadows.join(',') : undefined,
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const keyOf = (kind, payload) => {
|
|
1160
|
+
if (kind === 'folder') return 'folder:' + payload.path
|
|
1161
|
+
if (kind === 'workspace') return 'workspace:' + payload.workspaceId
|
|
1162
|
+
if (kind === 'session') return 'session:' + payload.id
|
|
1163
|
+
if (kind === 'sgroup') return 'sgroup:' + payload.workspaceId + '|' + payload.path
|
|
1164
|
+
return String(kind)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const accounted = new Set()
|
|
1168
|
+
for (const workspace of items || []) for (const id of workspace.sessionIds || []) accounted.add(id)
|
|
1169
|
+
const ungrouped = []
|
|
1170
|
+
if (list && Array.isArray(list.ids)) {
|
|
1171
|
+
for (const id of list.ids) {
|
|
1172
|
+
const summary = list.byId[id]
|
|
1173
|
+
if (accounted.has(id) || !sessionVisible(summary, list.current, archivedSet)) continue
|
|
1174
|
+
ungrouped.push({
|
|
1175
|
+
id,
|
|
1176
|
+
title: sessionTitleOf(summary, t),
|
|
1177
|
+
leaf: sessionTitleOf(summary, t),
|
|
1178
|
+
blank: !!summary.blank,
|
|
1179
|
+
running: !!summary.running,
|
|
1180
|
+
completed: summary.completed === true,
|
|
1181
|
+
subagents: subCounts.get(id) || 0,
|
|
1182
|
+
updatedAt: summary.updatedAt || 0,
|
|
1183
|
+
pending: pendingKindOf(pending, id),
|
|
1184
|
+
})
|
|
1185
|
+
}
|
|
1186
|
+
ungrouped.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const tree = React.useMemo(() => {
|
|
1190
|
+
const built = buildTree(items, storeFolders)
|
|
1191
|
+
if (!compactChains) return built
|
|
1192
|
+
return { ...built, folders: built.folders.map(compressTree), workspaces: built.workspaces }
|
|
1193
|
+
}, [items, storeFolders, compactChains])
|
|
1194
|
+
|
|
1195
|
+
const sessionsOf = (workspace) => {
|
|
1196
|
+
const rows = []
|
|
1197
|
+
for (const id of workspace.sessionIds || []) {
|
|
1198
|
+
const summary = list && list.byId ? list.byId[id] : undefined
|
|
1199
|
+
if (!sessionVisible(summary, list ? list.current : undefined, archivedSet)) continue
|
|
1200
|
+
rows.push({
|
|
1201
|
+
id,
|
|
1202
|
+
title: sessionTitleOf(summary, t),
|
|
1203
|
+
leaf: sessionTitleOf(summary, t),
|
|
1204
|
+
blank: !!summary.blank,
|
|
1205
|
+
running: !!summary.running,
|
|
1206
|
+
completed: summary.completed === true,
|
|
1207
|
+
subagents: subCounts.get(id) || 0,
|
|
1208
|
+
updatedAt: summary.updatedAt || 0,
|
|
1209
|
+
pending: pendingKindOf(pending, id),
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
return rows
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const searchAgent = (agent) => {
|
|
1216
|
+
// Returns a pruned copy of the tree node, or null when nothing matches.
|
|
1217
|
+
if (!normalizedQuery) return agent
|
|
1218
|
+
if (agent.kind === 'ws') {
|
|
1219
|
+
const ws = agent.workspace
|
|
1220
|
+
const sessions = sessionsOf(ws)
|
|
1221
|
+
const wsHit = ws.leaf.toLowerCase().includes(normalizedQuery) || ws.title.toLowerCase().includes(normalizedQuery)
|
|
1222
|
+
if (wsHit || sessions.some(s => s.title.toLowerCase().includes(normalizedQuery))) {
|
|
1223
|
+
return { ...agent, node: agent, folders: [], workspaces: [ws] }
|
|
1224
|
+
}
|
|
1225
|
+
return null
|
|
1226
|
+
}
|
|
1227
|
+
const folders = []
|
|
1228
|
+
for (const folder of agent.folders) {
|
|
1229
|
+
const hit = searchAgent(folder)
|
|
1230
|
+
if (hit) folders.push(hit)
|
|
1231
|
+
}
|
|
1232
|
+
const workspaces = []
|
|
1233
|
+
for (const workspace of agent.workspaces) {
|
|
1234
|
+
const sessions = sessionsOf(workspace)
|
|
1235
|
+
const wsHit = workspace.leaf.toLowerCase().includes(normalizedQuery) || workspace.title.toLowerCase().includes(normalizedQuery)
|
|
1236
|
+
const matchedSessions = wsHit ? sessions : sessions.filter(s => s.title.toLowerCase().includes(normalizedQuery))
|
|
1237
|
+
if (wsHit || matchedSessions.length > 0) workspaces.push({ workspace, matchedSessions })
|
|
1238
|
+
}
|
|
1239
|
+
if (folders.length === 0 && workspaces.length === 0) return null
|
|
1240
|
+
return { node: agent, folders, workspaces }
|
|
1241
|
+
}
|
|
1242
|
+
const searched = normalizedQuery ? searchAgent(tree) : null
|
|
1243
|
+
const searching = normalizedQuery !== ''
|
|
1244
|
+
|
|
1245
|
+
const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
|
|
1246
|
+
const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
|
|
1247
|
+
const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
|
|
1248
|
+
|
|
1249
|
+
const openCtx = (kind, payload, e) => {
|
|
1250
|
+
if (e) e.preventDefault()
|
|
1251
|
+
setCtx({ kind, payload, x: e.clientX, y: e.clientY })
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/* ------------------------- drag & drop -------------------------- */
|
|
1255
|
+
|
|
1256
|
+
React.useEffect(() => {
|
|
1257
|
+
if (drag === null) return
|
|
1258
|
+
const accept = (event) => {
|
|
1259
|
+
event.preventDefault()
|
|
1260
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
1261
|
+
}
|
|
1262
|
+
const acceptDrop = (event) => { event.preventDefault() }
|
|
1263
|
+
document.addEventListener('dragover', accept)
|
|
1264
|
+
document.addEventListener('drop', acceptDrop)
|
|
1265
|
+
return () => {
|
|
1266
|
+
document.removeEventListener('dragover', accept)
|
|
1267
|
+
document.removeEventListener('drop', acceptDrop)
|
|
1268
|
+
}
|
|
1269
|
+
}, [drag === null])
|
|
1270
|
+
|
|
1271
|
+
const rowHalf = (event) => {
|
|
1272
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
1273
|
+
return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
|
1274
|
+
}
|
|
1275
|
+
const dragMatches = (kind) => drag !== null && drag.kind === kind
|
|
1276
|
+
const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
|
|
1277
|
+
|
|
1278
|
+
/* --------------------- workspace drag & drop -------------------- */
|
|
1279
|
+
|
|
1280
|
+
const wsDropHalf = (workspaceId) => {
|
|
1281
|
+
if (!dragMatches('workspace')) return null
|
|
1282
|
+
const over = drag.over
|
|
1283
|
+
return over && over.kind === 'workspace' && over.target === workspaceId ? over.half : null
|
|
1284
|
+
}
|
|
1285
|
+
const wsDropInto = (path) => dragMatches('workspace') && drag.over && drag.over.kind === 'folder' && drag.over.target === path
|
|
1286
|
+
const workspaceDragEvents = (workspace) => ({
|
|
1287
|
+
draggable: !searching && canDragWorkspace,
|
|
1288
|
+
onDragStart: (event) => {
|
|
1289
|
+
if (searching || !canDragWorkspace) return
|
|
1290
|
+
event.stopPropagation()
|
|
1291
|
+
try {
|
|
1292
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
1293
|
+
event.dataTransfer.setData('text/plain', workspace.workspaceId)
|
|
1294
|
+
} catch { /* drag payload is best-effort */ }
|
|
1295
|
+
setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: workspace.leaf, folderPath: workspace.folderPath || '' }, over: null })
|
|
1296
|
+
},
|
|
1297
|
+
onDragEnd: () => setDrag(null),
|
|
1298
|
+
onDragOver: (event) => {
|
|
1299
|
+
if (!dragMatches('workspace')) return
|
|
1300
|
+
event.preventDefault()
|
|
1301
|
+
event.stopPropagation()
|
|
1302
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
1303
|
+
const half = rowHalf(event)
|
|
1304
|
+
setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
|
|
1305
|
+
? current
|
|
1306
|
+
: (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
|
|
1307
|
+
},
|
|
1308
|
+
onDrop: (event) => {
|
|
1309
|
+
if (!dragMatches('workspace')) return
|
|
1310
|
+
event.preventDefault()
|
|
1311
|
+
event.stopPropagation()
|
|
1312
|
+
const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
|
|
1313
|
+
commitWorkspaceDrop(workspace, half)
|
|
1314
|
+
},
|
|
1315
|
+
})
|
|
1316
|
+
const folderDropEvents = (path) => ({
|
|
1317
|
+
onDragOver: (event) => {
|
|
1318
|
+
if (!dragMatches('workspace')) return
|
|
1319
|
+
event.preventDefault()
|
|
1320
|
+
event.stopPropagation()
|
|
1321
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
1322
|
+
setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === path)
|
|
1323
|
+
? current
|
|
1324
|
+
: (current ? { ...current, over: { kind: 'folder', target: path } } : current))
|
|
1325
|
+
},
|
|
1326
|
+
onDrop: (event) => {
|
|
1327
|
+
if (!dragMatches('workspace')) return
|
|
1328
|
+
event.preventDefault()
|
|
1329
|
+
event.stopPropagation()
|
|
1330
|
+
commitWorkspaceMoveInto(path)
|
|
1331
|
+
},
|
|
1332
|
+
})
|
|
1333
|
+
const nextWorkspaceAfter = (folderPath, workspaceId) => {
|
|
1334
|
+
const node = findTreeNode(tree, folderPath)
|
|
1335
|
+
if (!node) return undefined
|
|
1336
|
+
const index = node.workspaces.findIndex(w => w.workspaceId === workspaceId)
|
|
1337
|
+
return index === -1 ? undefined : (node.workspaces[index + 1] ? node.workspaces[index + 1].workspaceId : undefined)
|
|
1338
|
+
}
|
|
1339
|
+
const commitWorkspaceDrop = (targetWorkspace, half) => {
|
|
1340
|
+
const source = drag.source
|
|
1341
|
+
setDrag(null)
|
|
1342
|
+
if (source.workspaceId === targetWorkspace.workspaceId) return
|
|
1343
|
+
const sameFolder = (targetWorkspace.folderPath || '') === source.folderPath
|
|
1344
|
+
const anchor = half === 'after'
|
|
1345
|
+
? nextWorkspaceAfter(targetWorkspace.folderPath || '', targetWorkspace.workspaceId)
|
|
1346
|
+
: targetWorkspace.workspaceId
|
|
1347
|
+
const chain = sameFolder
|
|
1348
|
+
? Promise.resolve()
|
|
1349
|
+
: Promise.resolve().then(() => {
|
|
1350
|
+
const newTitle = (targetWorkspace.folderPath || '') !== '' ? (targetWorkspace.folderPath || '') + '/' + source.leaf : source.leaf
|
|
1351
|
+
return renameWorkspace(source.workspaceId, newTitle)
|
|
1352
|
+
})
|
|
1353
|
+
chain
|
|
1354
|
+
.then(() => (anchor !== undefined ? insertWorkspaceBefore(source.workspaceId, anchor) : insertWorkspaceBefore(source.workspaceId)))
|
|
1355
|
+
.catch(fail)
|
|
1356
|
+
}
|
|
1357
|
+
const commitWorkspaceMoveInto = (folderPath) => {
|
|
1358
|
+
const source = drag.source
|
|
1359
|
+
setDrag(null)
|
|
1360
|
+
if (source.folderPath === folderPath) return
|
|
1361
|
+
const newTitle = folderPath !== '' ? folderPath + '/' + source.leaf : source.leaf
|
|
1362
|
+
Promise.resolve()
|
|
1363
|
+
.then(() => renameWorkspace(source.workspaceId, newTitle))
|
|
1364
|
+
.then(() => insertWorkspaceBefore(source.workspaceId))
|
|
1365
|
+
.catch(fail)
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/* ---------------------- session drag & drop --------------------- */
|
|
1369
|
+
|
|
1370
|
+
const sessDropHalf = (sessionId) => {
|
|
1371
|
+
if (!dragMatches('session')) return null
|
|
1372
|
+
const over = drag.over
|
|
1373
|
+
return over && over.kind === 'session' && over.target === sessionId ? over.half : null
|
|
1374
|
+
}
|
|
1375
|
+
const sgroupDropInto = (workspaceId, path) => dragMatches('session') && drag.over && drag.over.kind === 'sgroup' && drag.over.target === path
|
|
1376
|
+
const sessionDragEvents = (session, workspaceId) => ({
|
|
1377
|
+
draggable: !searching,
|
|
1378
|
+
onDragStart: (event) => {
|
|
1379
|
+
if (searching) return
|
|
1380
|
+
event.stopPropagation()
|
|
1381
|
+
try {
|
|
1382
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
1383
|
+
event.dataTransfer.setData('text/plain', session.id)
|
|
1384
|
+
} catch { }
|
|
1385
|
+
setDrag({ kind: 'session', source: { sessionId: session.id, workspaceId, title: session.title, leaf: session.leaf || session.title }, over: null })
|
|
1386
|
+
},
|
|
1387
|
+
onDragEnd: () => setDrag(null),
|
|
1388
|
+
onDragOver: (event) => {
|
|
1389
|
+
if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
|
|
1390
|
+
event.preventDefault()
|
|
1391
|
+
event.stopPropagation()
|
|
1392
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
1393
|
+
const half = rowHalf(event)
|
|
1394
|
+
setDrag(current => (current && current.over && current.over.kind === 'session' && current.over.target === session.id && current.over.half === half)
|
|
1395
|
+
? current
|
|
1396
|
+
: (current ? { ...current, over: { kind: 'session', target: session.id, half } } : current))
|
|
1397
|
+
},
|
|
1398
|
+
onDrop: (event) => {
|
|
1399
|
+
if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
|
|
1400
|
+
event.preventDefault()
|
|
1401
|
+
event.stopPropagation()
|
|
1402
|
+
const half = drag.over && drag.over.kind === 'session' && drag.over.target === session.id ? drag.over.half : rowHalf(event)
|
|
1403
|
+
commitSessionDrop(workspaceId, session.id, half)
|
|
1404
|
+
},
|
|
1405
|
+
})
|
|
1406
|
+
const sgroupDropEvents = (workspaceId, path) => ({
|
|
1407
|
+
onDragOver: (event) => {
|
|
1408
|
+
if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
|
|
1409
|
+
event.preventDefault()
|
|
1410
|
+
event.stopPropagation()
|
|
1411
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
1412
|
+
setDrag(current => (current && current.over && current.over.kind === 'sgroup' && current.over.target === path)
|
|
1413
|
+
? current
|
|
1414
|
+
: (current ? { ...current, over: { kind: 'sgroup', target: path } } : current))
|
|
1415
|
+
},
|
|
1416
|
+
onDrop: (event) => {
|
|
1417
|
+
if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
|
|
1418
|
+
event.preventDefault()
|
|
1419
|
+
event.stopPropagation()
|
|
1420
|
+
commitSessionMoveInto(workspaceId, path)
|
|
1421
|
+
},
|
|
1422
|
+
})
|
|
1423
|
+
const commitSessionDrop = (workspaceId, targetSessionId, half) => {
|
|
1424
|
+
const source = drag.source
|
|
1425
|
+
setDrag(null)
|
|
1426
|
+
if (source.sessionId === targetSessionId) return
|
|
1427
|
+
const workspace = (items || []).find(w => w.workspaceId === workspaceId)
|
|
1428
|
+
if (!workspace) return
|
|
1429
|
+
const flat = sessionsOf(workspace)
|
|
1430
|
+
const index = flat.findIndex(s => s.id === targetSessionId)
|
|
1431
|
+
const anchor = half === 'after'
|
|
1432
|
+
? (index === -1 ? undefined : (flat[index + 1] ? flat[index + 1].id : undefined))
|
|
1433
|
+
: targetSessionId
|
|
1434
|
+
Promise.resolve()
|
|
1435
|
+
.then(() => (anchor !== undefined ? insertSessionBefore(workspaceId, source.sessionId, anchor) : insertSessionBefore(workspaceId, source.sessionId)))
|
|
1436
|
+
.catch(fail)
|
|
1437
|
+
}
|
|
1438
|
+
const commitSessionMoveInto = (workspaceId, groupPath) => {
|
|
1439
|
+
const source = drag.source
|
|
1440
|
+
setDrag(null)
|
|
1441
|
+
const newTitle = groupPath !== '' ? groupPath + '/' + source.leaf : source.leaf
|
|
1442
|
+
if (newTitle === source.title) return
|
|
1443
|
+
Promise.resolve()
|
|
1444
|
+
.then(() => renameSession(source.sessionId, newTitle))
|
|
1445
|
+
.catch(fail)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/* --------------------------- actions --------------------------- */
|
|
1449
|
+
|
|
1450
|
+
const submitWorkspaceRename = (workspace, nextTitle) => {
|
|
1451
|
+
const title = String(nextTitle || '').trim()
|
|
1452
|
+
if (title === '' || title === workspace.title) { setDialog(null); return }
|
|
1453
|
+
Promise.resolve()
|
|
1454
|
+
.then(() => renameWorkspace(workspace.workspaceId, title))
|
|
1455
|
+
.then(() => setDialog(null))
|
|
1456
|
+
.catch(fail)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const submitWorkspaceDelete = (workspace) => {
|
|
1460
|
+
Promise.resolve()
|
|
1461
|
+
.then(() => deleteWorkspace(workspace.workspaceId))
|
|
1462
|
+
.then(() => setDialog(null))
|
|
1463
|
+
.catch(fail)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const submitSessionRename = (session, nextTitle) => {
|
|
1467
|
+
const title = String(nextTitle || '').trim()
|
|
1468
|
+
if (title === '' || title === session.title) { setDialog(null); return }
|
|
1469
|
+
Promise.resolve()
|
|
1470
|
+
.then(() => renameSession(session.id, title))
|
|
1471
|
+
.then(() => setDialog(null))
|
|
1472
|
+
.catch(fail)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Rename one session sub-group: rewrite the title prefix of every member. */
|
|
1476
|
+
const submitSessionGroupRename = (target, rawName) => {
|
|
1477
|
+
const name = normPath(rawName)
|
|
1478
|
+
if (name === '') { setErrorText(t('folder.error.empty')); return }
|
|
1479
|
+
if (name === target.name) { setDialog(null); return }
|
|
1480
|
+
const workspace = (items || []).find(w => w.workspaceId === target.workspaceId)
|
|
1481
|
+
if (!workspace) { setDialog(null); return }
|
|
1482
|
+
const node = findSessionGroup(buildSessionTree(sessionsOf(workspace)), target.path)
|
|
1483
|
+
if (!node) { setDialog(null); return }
|
|
1484
|
+
const parentPath = target.path.includes('/') ? target.path.slice(0, target.path.lastIndexOf('/')) : ''
|
|
1485
|
+
const nextPath = parentPath !== '' ? parentPath + '/' + name : name
|
|
1486
|
+
const affected = collectSessionRows(node)
|
|
1487
|
+
Promise.resolve()
|
|
1488
|
+
.then(async () => {
|
|
1489
|
+
for (const row of affected) {
|
|
1490
|
+
if (row.blank) continue
|
|
1491
|
+
const nextTitle = nextPath + row.title.slice(target.path.length)
|
|
1492
|
+
await renameSession(row.id, nextTitle)
|
|
1493
|
+
}
|
|
1494
|
+
})
|
|
1495
|
+
.then(() => setDialog(null))
|
|
1496
|
+
.catch(fail)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const findTreeNode = (node, path) => {
|
|
1500
|
+
if (node.path === path) return node
|
|
1501
|
+
for (const folder of node.folders) {
|
|
1502
|
+
const hit = findTreeNode(folder, path)
|
|
1503
|
+
if (hit) return hit
|
|
1504
|
+
}
|
|
1505
|
+
return null
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const submitFolderNew = (rawPath) => {
|
|
1509
|
+
const p = normPath(rawPath)
|
|
1510
|
+
if (p === '') { setErrorText(t('folder.error.empty')); return }
|
|
1511
|
+
if (storeFolders.includes(p)) { setErrorText(t('folder.error.exists')); return }
|
|
1512
|
+
actions.addFolder(p)
|
|
1513
|
+
setDialog(null)
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const submitFolderRename = (oldPath, rawPath) => {
|
|
1517
|
+
const newPath = normPath(rawPath)
|
|
1518
|
+
if (newPath === '') { setErrorText(t('folder.error.empty')); return }
|
|
1519
|
+
if (newPath === oldPath) { setDialog(null); return }
|
|
1520
|
+
const node = findTreeNode(tree, oldPath)
|
|
1521
|
+
const hasChildren = node ? countWorkspaces(node) > 0 : false
|
|
1522
|
+
const prefix = oldPath + '/'
|
|
1523
|
+
const affected = hasChildren
|
|
1524
|
+
? (items || []).filter(w => String(w.title || '').startsWith(prefix))
|
|
1525
|
+
: []
|
|
1526
|
+
Promise.resolve()
|
|
1527
|
+
.then(async () => {
|
|
1528
|
+
for (const w of affected) {
|
|
1529
|
+
const nextTitle = newPath + String(w.title).slice(oldPath.length)
|
|
1530
|
+
await renameWorkspace(w.workspaceId, nextTitle)
|
|
1531
|
+
}
|
|
1532
|
+
})
|
|
1533
|
+
.then(() => { actions.renameFolder(oldPath, newPath); setDialog(null) })
|
|
1534
|
+
.catch(fail)
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const submitFolderDelete = (path) => {
|
|
1538
|
+
const node = findTreeNode(tree, path)
|
|
1539
|
+
if (node && countWorkspaces(node) > 0) { setErrorText(t('folder.error.notEmpty')); return }
|
|
1540
|
+
actions.removeFolder(path)
|
|
1541
|
+
setDialog(null)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/* ---------------------------- rows ----------------------------- */
|
|
1545
|
+
|
|
1546
|
+
const renderSessionTree = (workspace, depth) => {
|
|
1547
|
+
// Folder semantics: a closed workspace shows no sessions at all (the
|
|
1548
|
+
// row keeps its count badge); an open one shows the full session tree.
|
|
1549
|
+
if (!searching && !sessionsOpenOf(workspace.workspaceId)) return []
|
|
1550
|
+
const rows = sessionsOf(workspace)
|
|
1551
|
+
if (rows.length === 0) return []
|
|
1552
|
+
return renderSessionNode(buildSessionTree(rows), workspace.workspaceId, depth)
|
|
1553
|
+
}
|
|
1554
|
+
const searchSessionNode = (node) => {
|
|
1555
|
+
const groups = []
|
|
1556
|
+
for (const group of node.groups) {
|
|
1557
|
+
const hit = searchSessionNode(group)
|
|
1558
|
+
if (hit) groups.push(hit)
|
|
1559
|
+
}
|
|
1560
|
+
const sessions = node.sessions.filter(s => ((s.leaf || s.title) + ' ' + s.title).toLowerCase().includes(normalizedQuery))
|
|
1561
|
+
if (groups.length === 0 && sessions.length === 0) return null
|
|
1562
|
+
return { path: node.path, name: node.name, groups, sessions }
|
|
1563
|
+
}
|
|
1564
|
+
const renderSessionNode = (node, workspaceId, depth) => {
|
|
1565
|
+
const view = searching ? searchSessionNode(node) : node
|
|
1566
|
+
if (!view) return []
|
|
1567
|
+
const out = []
|
|
1568
|
+
for (const group of view.groups) {
|
|
1569
|
+
const key = workspaceId + '|' + group.path
|
|
1570
|
+
const open = searching || sessionGroupOpen(key)
|
|
1571
|
+
out.push(E(SessionGroupRow, {
|
|
1572
|
+
key: 'sg-' + key,
|
|
1573
|
+
name: group.name,
|
|
1574
|
+
depth,
|
|
1575
|
+
expanded: open,
|
|
1576
|
+
count: countSessionTree(group),
|
|
1577
|
+
onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
|
|
1578
|
+
onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
|
|
1579
|
+
dropInto: sgroupDropInto(workspaceId, group.path),
|
|
1580
|
+
dragEvents: sgroupDropEvents(workspaceId, group.path),
|
|
1581
|
+
custStyle: rowStyleOf('sgroup:' + workspaceId + '|' + group.path),
|
|
1582
|
+
t,
|
|
1583
|
+
}))
|
|
1584
|
+
if (open) out.push(...renderSessionNode(group, workspaceId, depth + 1))
|
|
1585
|
+
}
|
|
1586
|
+
for (const s of view.sessions) out.push(renderSessionRow(s, depth, workspaceId))
|
|
1587
|
+
return out
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const renderSessionRow = (session, depth, workspaceId) => E(SessionRow, {
|
|
1591
|
+
key: session.id,
|
|
1592
|
+
node: session,
|
|
1593
|
+
depth,
|
|
1594
|
+
current: list && list.current === session.id,
|
|
1595
|
+
now,
|
|
1596
|
+
onOpen: (id) => open(id),
|
|
1597
|
+
onContextMenu: (e) => openCtx('session', session, e),
|
|
1598
|
+
dropHalf: workspaceId ? sessDropHalf(session.id) : null,
|
|
1599
|
+
dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
|
|
1600
|
+
custStyle: rowStyleOf('session:' + session.id),
|
|
1601
|
+
t,
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
const renderWorkspaceEntry = (entry, depth) => {
|
|
1605
|
+
const { workspace } = entry
|
|
1606
|
+
const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
|
|
1607
|
+
const rows = [E(WorkspaceRow, {
|
|
1608
|
+
key: 'ws-' + workspace.workspaceId,
|
|
1609
|
+
workspace,
|
|
1610
|
+
depth,
|
|
1611
|
+
count,
|
|
1612
|
+
sessionsOpen: searching ? true : sessionsOpenOf(workspace.workspaceId),
|
|
1613
|
+
currentInside: !!(list && list.current && (workspace.sessionIds || []).includes(list.current)),
|
|
1614
|
+
onToggle: () => { if (!searching) actions.setSessionsExpanded(workspace.workspaceId, !sessionsOpenOf(workspace.workspaceId)) },
|
|
1615
|
+
onStart: () => startSession(workspace.workspaceId),
|
|
1616
|
+
onContextMenu: (e) => openCtx('workspace', workspace, e),
|
|
1617
|
+
dropHalf: wsDropHalf(workspace.workspaceId),
|
|
1618
|
+
dragEvents: workspaceDragEvents(workspace),
|
|
1619
|
+
custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
|
|
1620
|
+
iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
|
|
1621
|
+
t,
|
|
1622
|
+
})]
|
|
1623
|
+
rows.push(...renderSessionTree(workspace, depth + 1))
|
|
1624
|
+
return rows
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const renderPlainFolder = (node, depth) => {
|
|
1628
|
+
if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
|
|
1629
|
+
const expanded = searching || folderExpanded(node.path)
|
|
1630
|
+
const rows = [E(FolderRow, {
|
|
1631
|
+
key: 'f-' + node.path,
|
|
1632
|
+
node,
|
|
1633
|
+
depth,
|
|
1634
|
+
expanded,
|
|
1635
|
+
onToggle: () => { if (!searching) actions.setExpanded(node.path, !expanded) },
|
|
1636
|
+
onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name }, e),
|
|
1637
|
+
dropInto: wsDropInto(node.path),
|
|
1638
|
+
dragEvents: folderDropEvents(node.path),
|
|
1639
|
+
custStyle: rowStyleOf('folder:' + node.path),
|
|
1640
|
+
iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
|
|
1641
|
+
t,
|
|
1642
|
+
})]
|
|
1643
|
+
if (expanded) {
|
|
1644
|
+
for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
|
|
1645
|
+
for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1))
|
|
1646
|
+
}
|
|
1647
|
+
return rows
|
|
1648
|
+
}
|
|
1649
|
+
const renderSearchedFolder = (hit, depth) => {
|
|
1650
|
+
if (hit.kind === 'ws') return renderWorkspaceEntry({ workspace: hit.workspace }, depth)
|
|
1651
|
+
const node = hit.node
|
|
1652
|
+
const rows = [E(FolderRow, {
|
|
1653
|
+
key: 'f-' + node.path,
|
|
1654
|
+
node,
|
|
1655
|
+
depth,
|
|
1656
|
+
expanded: true,
|
|
1657
|
+
onToggle: () => {},
|
|
1658
|
+
onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name }, e),
|
|
1659
|
+
dropInto: false,
|
|
1660
|
+
dragEvents: undefined,
|
|
1661
|
+
custStyle: rowStyleOf('folder:' + node.path),
|
|
1662
|
+
iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
|
|
1663
|
+
t,
|
|
1664
|
+
})]
|
|
1665
|
+
for (const child of hit.folders) rows.push(...renderSearchedFolder(child, depth + 1))
|
|
1666
|
+
for (const entry of hit.workspaces) rows.push(...renderWorkspaceEntry(entry, depth + 1))
|
|
1667
|
+
return rows
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
let bodyRows = []
|
|
1671
|
+
if (searching) {
|
|
1672
|
+
if (searched) {
|
|
1673
|
+
for (const child of searched.folders) bodyRows.push(...renderSearchedFolder(child, 0))
|
|
1674
|
+
for (const entry of searched.workspaces) bodyRows.push(...renderWorkspaceEntry(entry, 0))
|
|
1675
|
+
}
|
|
1676
|
+
} else {
|
|
1677
|
+
for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
|
|
1678
|
+
for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0))
|
|
1679
|
+
if (ungrouped.length > 0) {
|
|
1680
|
+
bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
|
|
1681
|
+
for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const isEmpty = bodyRows.length === 0
|
|
1685
|
+
if (isEmpty) {
|
|
1686
|
+
bodyRows = [E('div', { key: 'empty', className: 'bw-empty' }, searching ? t('empty.search') : (phase === 'pending' ? '…' : t('empty')))]
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/* ------------------------- context menu ------------------------ */
|
|
1690
|
+
|
|
1691
|
+
const ctxItems = () => {
|
|
1692
|
+
if (ctx === null) return []
|
|
1693
|
+
if (ctx.kind === 'folder') {
|
|
1694
|
+
const items = [{ id: 'rename-folder', label: t('menu.renameFolder') }]
|
|
1695
|
+
if (storeFolders.includes(ctx.payload.path)) items.push({ id: 'remove-folder', label: t('menu.removeFolder'), danger: true })
|
|
1696
|
+
items.push({ sep: true })
|
|
1697
|
+
items.push({ id: 'customize', label: t('custom.title') })
|
|
1698
|
+
return items
|
|
1699
|
+
}
|
|
1700
|
+
if (ctx.kind === 'workspace') return [
|
|
1701
|
+
{ id: 'rename', label: t('menu.rename') },
|
|
1702
|
+
{ id: 'delete', label: t('menu.delete'), danger: true },
|
|
1703
|
+
{ sep: true },
|
|
1704
|
+
{ id: 'customize', label: t('custom.title') },
|
|
1705
|
+
]
|
|
1706
|
+
if (ctx.kind === 'session') return [
|
|
1707
|
+
{ id: 'rename', label: t('menu.rename') },
|
|
1708
|
+
{ id: 'fork', label: t('menu.fork') },
|
|
1709
|
+
{ id: 'archive', label: t('menu.archive'), danger: true },
|
|
1710
|
+
{ sep: true },
|
|
1711
|
+
{ id: 'customize', label: t('custom.title') },
|
|
1712
|
+
]
|
|
1713
|
+
return [
|
|
1714
|
+
{ id: 'rename-sgroup', label: t('menu.renameSgroup') },
|
|
1715
|
+
{ sep: true },
|
|
1716
|
+
{ id: 'customize', label: t('custom.title') },
|
|
1717
|
+
]
|
|
1718
|
+
}
|
|
1719
|
+
const handleCtxPick = (id) => {
|
|
1720
|
+
const current = ctx
|
|
1721
|
+
if (current === null) return
|
|
1722
|
+
if (id === 'customize') {
|
|
1723
|
+
const payload = current.payload
|
|
1724
|
+
const name = current.kind === 'workspace' ? (payload.title || payload.leaf)
|
|
1725
|
+
: (current.kind === 'session' ? payload.title : payload.name)
|
|
1726
|
+
setCustomize({ kind: current.kind, entryKey: keyOf(current.kind, payload), name })
|
|
1727
|
+
setCtx(null)
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
const { kind, payload } = current
|
|
1731
|
+
setCtx(null)
|
|
1732
|
+
if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
|
|
1733
|
+
else if (kind === 'folder' && id === 'remove-folder') setDialog({ kind: 'folder-delete', path: payload.path })
|
|
1734
|
+
else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
|
|
1735
|
+
else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
|
|
1736
|
+
else if (kind === 'sgroup' && id === 'rename-sgroup') setDialog({ kind: 'sgroup-rename', target: payload })
|
|
1737
|
+
else if (kind === 'session' && id === 'rename') setDialog({ kind: 'sess-rename', session: payload })
|
|
1738
|
+
else if (kind === 'session' && id === 'fork') forkSession(payload.id)
|
|
1739
|
+
else if (kind === 'session' && id === 'archive') { Promise.resolve().then(() => archiveSession(payload.id)).catch(fail) }
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/* --------------------------- dialogs --------------------------- */
|
|
1743
|
+
// TextDialog / ConfirmDialog are module-level components: a per-render
|
|
1744
|
+
// inline definition would remount on every parent tick and drop input.
|
|
1745
|
+
|
|
1746
|
+
/* ---------------------------- render --------------------------- */
|
|
1747
|
+
|
|
1748
|
+
if (!wide) {
|
|
1749
|
+
return E('div', { className: 'bw-rail' },
|
|
1750
|
+
StyleNode(),
|
|
1751
|
+
E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.search'), onClick: () => { expandSidebar(); setSearchOpen(true) } }, icon('IconSearchOutline16', 18)),
|
|
1752
|
+
E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
|
|
1753
|
+
)
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const dialogElement = (() => {
|
|
1757
|
+
if (dialog === null) return null
|
|
1758
|
+
if (dialog.kind === 'ws-rename') return E(TextDialog, {
|
|
1759
|
+
key: 'ws-rename',
|
|
1760
|
+
title: t('ws.rename.title'),
|
|
1761
|
+
hint: t('ws.rename.hint'),
|
|
1762
|
+
initial: dialog.workspace.title || dialog.workspace.leaf,
|
|
1763
|
+
onConfirm: (v) => submitWorkspaceRename(dialog.workspace, v),
|
|
1764
|
+
onClose: () => setDialog(null),
|
|
1765
|
+
t,
|
|
1766
|
+
})
|
|
1767
|
+
if (dialog.kind === 'ws-delete') return E(ConfirmDialog, {
|
|
1768
|
+
key: 'ws-delete',
|
|
1769
|
+
title: t('ws.delete.title'),
|
|
1770
|
+
body: t('ws.delete.body', { name: dialog.workspace.title || dialog.workspace.leaf }),
|
|
1771
|
+
onConfirm: () => submitWorkspaceDelete(dialog.workspace),
|
|
1772
|
+
onClose: () => setDialog(null),
|
|
1773
|
+
t,
|
|
1774
|
+
})
|
|
1775
|
+
if (dialog.kind === 'sess-rename') return E(TextDialog, {
|
|
1776
|
+
key: 'sess-rename',
|
|
1777
|
+
title: t('menu.rename'),
|
|
1778
|
+
initial: dialog.session.title,
|
|
1779
|
+
onConfirm: (v) => submitSessionRename(dialog.session, v),
|
|
1780
|
+
onClose: () => setDialog(null),
|
|
1781
|
+
t,
|
|
1782
|
+
})
|
|
1783
|
+
if (dialog.kind === 'sgroup-rename') return E(TextDialog, {
|
|
1784
|
+
key: 'sgroup-rename',
|
|
1785
|
+
title: t('menu.renameSgroup'),
|
|
1786
|
+
hint: t('ws.rename.hint'),
|
|
1787
|
+
initial: dialog.target.name,
|
|
1788
|
+
onConfirm: (v) => submitSessionGroupRename(dialog.target, v),
|
|
1789
|
+
onClose: () => setDialog(null),
|
|
1790
|
+
t,
|
|
1791
|
+
})
|
|
1792
|
+
if (dialog.kind === 'folder-new') return E(TextDialog, {
|
|
1793
|
+
key: 'folder-new',
|
|
1794
|
+
title: t('folder.new.title'),
|
|
1795
|
+
hint: t('folder.new.hint'),
|
|
1796
|
+
initial: '',
|
|
1797
|
+
onConfirm: submitFolderNew,
|
|
1798
|
+
onClose: () => setDialog(null),
|
|
1799
|
+
t,
|
|
1800
|
+
})
|
|
1801
|
+
if (dialog.kind === 'folder-rename') return E(TextDialog, {
|
|
1802
|
+
key: 'folder-rename',
|
|
1803
|
+
title: t('folder.rename.title'),
|
|
1804
|
+
hint: t('folder.rename.hint'),
|
|
1805
|
+
initial: dialog.path,
|
|
1806
|
+
onConfirm: (v) => submitFolderRename(dialog.path, v),
|
|
1807
|
+
onClose: () => setDialog(null),
|
|
1808
|
+
t,
|
|
1809
|
+
})
|
|
1810
|
+
if (dialog.kind === 'folder-delete') {
|
|
1811
|
+
return E(ConfirmDialog, {
|
|
1812
|
+
key: 'folder-delete',
|
|
1813
|
+
title: t('menu.removeFolder'),
|
|
1814
|
+
body: t('folder.delete.body', { name: dialog.path }),
|
|
1815
|
+
onConfirm: () => submitFolderDelete(dialog.path),
|
|
1816
|
+
onClose: () => setDialog(null),
|
|
1817
|
+
t,
|
|
1818
|
+
})
|
|
1819
|
+
}
|
|
1820
|
+
return null
|
|
1821
|
+
})()
|
|
1822
|
+
|
|
1823
|
+
const flowOwner = {
|
|
1824
|
+
open: flowOpen,
|
|
1825
|
+
busy: false,
|
|
1826
|
+
onPicked: () => {},
|
|
1827
|
+
onCancel: () => setFlowOpen(false),
|
|
1828
|
+
onError: fail,
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
return E('div', { className: 'bw-root' },
|
|
1832
|
+
StyleNode(),
|
|
1833
|
+
E('div', { className: 'bw-header' },
|
|
1834
|
+
E('div', { className: 'bw-header-title' }, t('title')),
|
|
1835
|
+
(searchOpen || query !== '') ? E('input', {
|
|
1836
|
+
className: 'bw-input',
|
|
1837
|
+
style: { width: 130, flex: 'none' },
|
|
1838
|
+
value: query,
|
|
1839
|
+
autoFocus: true,
|
|
1840
|
+
placeholder: t('search.placeholder'),
|
|
1841
|
+
onChange: (e) => setQuery(e.target.value),
|
|
1842
|
+
onKeyDown: (e) => { if (e.key === 'Escape') { setQuery(''); setSearchOpen(false) } },
|
|
1843
|
+
onBlur: () => { if (query === '') setSearchOpen(false) },
|
|
1844
|
+
}) : null,
|
|
1845
|
+
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
|
|
1846
|
+
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('newFolder'), onClick: () => setDialog({ kind: 'folder-new' }) }, E(FolderPlusIcon, { size: 16 })),
|
|
1847
|
+
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => setFlowOpen(true) }, icon('IconProjectAddOutline16')),
|
|
1848
|
+
),
|
|
1849
|
+
E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
|
|
1850
|
+
E(BetterFlow, {
|
|
1851
|
+
open: flowOpen,
|
|
1852
|
+
busy: false,
|
|
1853
|
+
onPicked: flowOwner.onPicked,
|
|
1854
|
+
onCancel: flowOwner.onCancel,
|
|
1855
|
+
onError: flowOwner.onError,
|
|
1856
|
+
createWorkspace,
|
|
1857
|
+
renameWorkspace,
|
|
1858
|
+
pickDirectory,
|
|
1859
|
+
useWorkspaces,
|
|
1860
|
+
actions,
|
|
1861
|
+
useStore,
|
|
1862
|
+
t,
|
|
1863
|
+
}),
|
|
1864
|
+
dialogElement,
|
|
1865
|
+
ctx !== null ? E('div', {
|
|
1866
|
+
className: 'bw-ctx-overlay',
|
|
1867
|
+
onMouseDown: () => setCtx(null),
|
|
1868
|
+
onContextMenu: (e) => e.preventDefault(),
|
|
1869
|
+
},
|
|
1870
|
+
E('div', {
|
|
1871
|
+
className: 'bw-ctx-menu',
|
|
1872
|
+
style: { left: Math.min(ctx.x, window.innerWidth - 190), top: Math.min(ctx.y, window.innerHeight - 240) },
|
|
1873
|
+
onMouseDown: (e) => e.stopPropagation(),
|
|
1874
|
+
onContextMenu: (e) => e.preventDefault(),
|
|
1875
|
+
},
|
|
1876
|
+
ctxItems().map((item, index) => item.sep
|
|
1877
|
+
? E('div', { key: 'sep-' + index, className: 'bw-ctx-sep' })
|
|
1878
|
+
: E('button', {
|
|
1879
|
+
key: item.id,
|
|
1880
|
+
type: 'button',
|
|
1881
|
+
className: cls('bw-ctx-item', item.danger && 'bw-ctx-danger'),
|
|
1882
|
+
onClick: () => handleCtxPick(item.id),
|
|
1883
|
+
}, item.label),
|
|
1884
|
+
),
|
|
1885
|
+
),
|
|
1886
|
+
) : null,
|
|
1887
|
+
E(CustomizeDialog, {
|
|
1888
|
+
open: customize !== null,
|
|
1889
|
+
kind: customize ? customize.kind : undefined,
|
|
1890
|
+
initial: customize ? styleEntry(customize.entryKey) : undefined,
|
|
1891
|
+
onChange: (style) => { if (customize) actions.setStyling(customize.entryKey, style) },
|
|
1892
|
+
onReset: () => { if (customize) actions.setStyling(customize.entryKey, null) },
|
|
1893
|
+
onClose: () => setCustomize(null),
|
|
1894
|
+
t,
|
|
1895
|
+
}),
|
|
1896
|
+
E(ui.Modal, {
|
|
1897
|
+
open: errorText !== null,
|
|
1898
|
+
onClose: () => setErrorText(null),
|
|
1899
|
+
closeLabel: t('close'),
|
|
1900
|
+
title: t('error.title'),
|
|
1901
|
+
footer: E('div', { className: 'bw-modal-actions' }, E(BTN, { variant: 'primary', onClick: () => setErrorText(null) }, t('close'))),
|
|
1902
|
+
}, E('div', { className: 'bw-modal-body' }, E('div', { className: 'bw-error-text', role: 'alert' }, errorText || '')), StyleNode()),
|
|
1903
|
+
)
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/* ============================ plugin ============================== */
|
|
1907
|
+
|
|
1908
|
+
const flowSource = (slots, hole) => ({
|
|
1909
|
+
getSnapshot: () => {
|
|
1910
|
+
try { return slots.entries(hole).length > 0 } catch { return false }
|
|
1911
|
+
},
|
|
1912
|
+
subscribe: (listener) => {
|
|
1913
|
+
try { return slots.subscribe(hole, listener) } catch { return () => {} }
|
|
1914
|
+
},
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
function apply(ctx) {
|
|
1918
|
+
const slots = ctx.get('slots')
|
|
1919
|
+
if (slots === undefined || typeof slots.register !== 'function') {
|
|
1920
|
+
console.error('[dsh-better-workspace] slots service unavailable; plugin idle')
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
const sessions = ctx.get('sessions')
|
|
1924
|
+
const workspaces = ctx.get('workspaces')
|
|
1925
|
+
const uiWorkspace = ctx.get('uiWorkspace')
|
|
1926
|
+
const connection = ctx.get('connection')
|
|
1927
|
+
if (!sessions || !workspaces || !uiWorkspace) {
|
|
1928
|
+
console.error('[dsh-better-workspace] required services missing', {
|
|
1929
|
+
sessions: !!sessions, workspaces: !!workspaces, uiWorkspace: !!uiWorkspace,
|
|
1930
|
+
})
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
1935
|
+
ctx.effect(() => {
|
|
1936
|
+
try {
|
|
1937
|
+
return ctx.locale.register(NS, { zh, en })
|
|
1938
|
+
} catch (localeError) {
|
|
1939
|
+
console.warn('[dsh-better-workspace] dictionary registration failed', localeError)
|
|
1940
|
+
return () => {}
|
|
1941
|
+
}
|
|
1942
|
+
}, 'better-workspace: dictionaries')
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const searchSessions = async (query, signal) => {
|
|
1946
|
+
const result = await sessions.search(query, signal)
|
|
1947
|
+
if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session search failed')
|
|
1948
|
+
return result.value
|
|
1949
|
+
}
|
|
1950
|
+
const renameSession = async (sessionId, title) => {
|
|
1951
|
+
const binding = sessions.binding(sessionId)
|
|
1952
|
+
const session = binding && binding.session
|
|
1953
|
+
if (!session) throw new Error('unknown session "' + sessionId + '"')
|
|
1954
|
+
const result = await session.rename(title)
|
|
1955
|
+
if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
|
|
1956
|
+
}
|
|
1957
|
+
const forkSession = (sessionId) => {
|
|
1958
|
+
sessions.fork({ sessionId, increaseTitle: true })
|
|
1959
|
+
.then((childId) => sessions.open(childId))
|
|
1960
|
+
.catch(() => { /* keep current selection */ })
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
const browserInjected = () => ({
|
|
1964
|
+
startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
|
|
1965
|
+
open: (sessionId) => { sessions.open(sessionId) },
|
|
1966
|
+
searchSessions,
|
|
1967
|
+
searchResultLimit: sessions.searchResultLimit !== undefined ? sessions.searchResultLimit : 20,
|
|
1968
|
+
renameSession,
|
|
1969
|
+
forkSession,
|
|
1970
|
+
renameWorkspace: (workspaceId, title) => workspaces.rename(workspaceId, title),
|
|
1971
|
+
deleteWorkspace: (workspaceId) => workspaces.delete(workspaceId),
|
|
1972
|
+
insertWorkspaceBefore: typeof workspaces.insertBefore === 'function'
|
|
1973
|
+
? (workspaceId, beforeWorkspaceId) => workspaces.insertBefore(workspaceId, beforeWorkspaceId)
|
|
1974
|
+
: undefined,
|
|
1975
|
+
archiveSession: (sessionId) => uiWorkspace.archiveSession(sessionId),
|
|
1976
|
+
createWorkspace: (input) => workspaces.create(input),
|
|
1977
|
+
pickDirectory: () => uiWorkspace.pickDirectory(),
|
|
1978
|
+
hooks: {
|
|
1979
|
+
directoryFlow: flowSource(slots, 'sidebar.workspaces.directoryFlow'),
|
|
1980
|
+
connectionGeneration: connection ? connection.generation : undefined,
|
|
1981
|
+
},
|
|
1982
|
+
})
|
|
1983
|
+
const flowInjected = (hole) => () => ({
|
|
1984
|
+
createWorkspace: (input) => workspaces.create(input),
|
|
1985
|
+
renameWorkspace: (workspaceId, title) => workspaces.rename(workspaceId, title),
|
|
1986
|
+
pickDirectory: () => uiWorkspace.pickDirectory(),
|
|
1987
|
+
hooks: { directoryFlow: flowSource(slots, hole) },
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1990
|
+
// Registration helper: a thrown register (semantics drift, vanishing
|
|
1991
|
+
// hole declaration mid-transition) degrades this one seat, never the
|
|
1992
|
+
// plugin fiber — the whole web boot is all-or-nothing.
|
|
1993
|
+
const guarded = (slotKey, options, component) => () => {
|
|
1994
|
+
try {
|
|
1995
|
+
return slots.register(options, (props) => E(QuietBoundary, null, E(component, props)))
|
|
1996
|
+
} catch (registerError) {
|
|
1997
|
+
console.warn('[dsh-better-workspace] register skipped for ' + slotKey, registerError)
|
|
1998
|
+
return undefined
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// 1+2. the two directory-flow holes (hero picker, shipped sidebar browser).
|
|
2003
|
+
slots.inject('conversation.hero.workspace.directoryFlow', guarded(
|
|
2004
|
+
'conversation.hero.workspace.directoryFlow',
|
|
2005
|
+
{ name: 'conversation.hero.workspace.directoryFlow', inject: flowInjected('conversation.hero.workspace.directoryFlow'), locale: NS },
|
|
2006
|
+
BetterFlow,
|
|
2007
|
+
))
|
|
2008
|
+
slots.inject('sidebar.workspaces.directoryFlow', guarded(
|
|
2009
|
+
'sidebar.workspaces.directoryFlow',
|
|
2010
|
+
{ name: 'sidebar.workspaces.directoryFlow', inject: flowInjected('sidebar.workspaces.directoryFlow'), locale: NS },
|
|
2011
|
+
BetterFlow,
|
|
2012
|
+
))
|
|
2013
|
+
|
|
2014
|
+
// One shared store handle: the browser and the settings page must see the
|
|
2015
|
+
// same persisted state (expansion, folder list, prefs, styling).
|
|
2016
|
+
const viewStore = createViewStore()
|
|
2017
|
+
|
|
2018
|
+
// Settings → Plugins card only (the tab dispatches the intersection of
|
|
2019
|
+
// served namespaces — registered host-side — and settings.plugin.item
|
|
2020
|
+
// cards). The old left-nav settings.section entry was removed: the
|
|
2021
|
+
// plugins-section card is the single settings surface now.
|
|
2022
|
+
slots.inject('settings.plugin.item', guarded(
|
|
2023
|
+
'settings.plugin.item',
|
|
2024
|
+
{
|
|
2025
|
+
name: 'settings.plugin.item',
|
|
2026
|
+
key: 'better-workspace',
|
|
2027
|
+
locale: NS,
|
|
2028
|
+
store: viewStore,
|
|
2029
|
+
},
|
|
2030
|
+
BetterWorkspacePluginCard,
|
|
2031
|
+
))
|
|
2032
|
+
|
|
2033
|
+
// 3. the browser itself — lowest priority renders, shadowing the shipped entry.
|
|
2034
|
+
slots.inject('sidebar.workspaces', guarded(
|
|
2035
|
+
'sidebar.workspaces',
|
|
2036
|
+
{
|
|
2037
|
+
name: 'sidebar.workspaces',
|
|
2038
|
+
priority: -1,
|
|
2039
|
+
store: viewStore,
|
|
2040
|
+
inject: browserInjected,
|
|
2041
|
+
locale: NS,
|
|
2042
|
+
},
|
|
2043
|
+
BetterBrowser,
|
|
2044
|
+
))
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
return {
|
|
2048
|
+
name: 'dsh-better-workspace',
|
|
2049
|
+
inject: ['slots', 'sessions', 'workspaces', 'locale', 'connection', 'uiWorkspace'],
|
|
2050
|
+
apply,
|
|
2051
|
+
}
|
|
2052
|
+
},
|
|
2053
|
+
})
|