dsh-better-workspace 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/README_EN.md +6 -5
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +297 -99
package/README.md
CHANGED
|
@@ -23,9 +23,9 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
23
23
|
|
|
24
24
|
点「添加工作区」并选完文件夹后,弹出一个小对话框,一行选择工作区所属分组——直接输入(如 `web`)、从下拉里选已有层级(datalist 原生补全),留空就是根分组。确认后插件创建工作区并把分组前缀写进名称,树上自然落位。
|
|
25
25
|
|
|
26
|
-
###
|
|
26
|
+
### 分组的增删
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
右键任意分组行即可:**新增子分组**(路径自动带上父分组前缀)、**新增子工作区**(选完目录后所属分组输入框自动预填)、重命名分组、删除空分组;标题栏不再放独立按钮。空分组持久保存在浏览器本地;一旦有工作区落进去,分组就由名称自然派生。
|
|
29
29
|
|
|
30
30
|
### 会话也吃这套规则
|
|
31
31
|
|
|
@@ -45,14 +45,14 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
45
45
|
<table>
|
|
46
46
|
<tr>
|
|
47
47
|
<td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="右键菜单"/></td>
|
|
48
|
-
<td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 /
|
|
48
|
+
<td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观;工作区分组行——新增子分组 / 新增子工作区 / 重命名分组(同步更新组内所有工作区名称)/ 删除空分组 / 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观。</td>
|
|
49
49
|
</tr>
|
|
50
50
|
</table>
|
|
51
51
|
|
|
52
52
|
<table>
|
|
53
53
|
<tr>
|
|
54
54
|
<td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="插件设置卡片"/></td>
|
|
55
|
-
<td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易);展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
|
|
55
|
+
<td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>状态呼吸灯</b>开关(被折叠藏起的状态灯沿层级向外冒泡呼吸发光,默认开);展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
|
|
56
56
|
</tr>
|
|
57
57
|
</table>
|
|
58
58
|
|
|
@@ -60,7 +60,8 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
60
60
|
|
|
61
61
|
- **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标);会话分组同样可收起;搜索时自动全部展开。
|
|
62
62
|
- **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组;会话行可拖拽——同工作区拖到另一会话上/下调整顺序,拖到会话子分组即移动进该分组。显示顺序 = 宿主手动序,拖拽结果立即生效。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
|
|
63
|
-
- **不丢原生能力**:会话行(打开/重命名/分叉/归档)
|
|
63
|
+
- **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
|
|
64
|
+
- **URL 标题感知**:会话/工作区标题里的 URL(如 `https://host/api/v1/x`)按 URL 语义分层——域名与协议合成一段、路径各段自成层级,同前缀 URL 自动聚到同一分组下,不再被撕成伪分组。
|
|
64
65
|
- **对话空态页同样生效**:会话空态页的「添加工作区」菜单走的也是本插件的拾取交互,同样带所属分组弹窗。
|
|
65
66
|
|
|
66
67
|
## 安装
|
package/README_EN.md
CHANGED
|
@@ -21,9 +21,9 @@ web/ <- virtual folder (naming only, not a real directory)
|
|
|
21
21
|
|
|
22
22
|
After you pick a directory for a new workspace, a small dialog asks for the parent group — type one (`web`), pick an existing level from the datalist, or leave it empty for the root. The plugin creates the workspace and writes the prefix into its title, so it lands on the right branch of the tree.
|
|
23
23
|
|
|
24
|
-
###
|
|
24
|
+
### Folder create/delete via context menu
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Right-click any folder row: new subfolder (parent prefix pre-filled), new workspace here (the add-flow group field pre-fills with this folder), rename folder, delete empty folder — no standalone header button anymore. Explicit empty folders persist in the browser until workspaces live inside them.
|
|
27
27
|
|
|
28
28
|
### Sessions follow the same rule
|
|
29
29
|
|
|
@@ -43,14 +43,14 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
43
43
|
<table>
|
|
44
44
|
<tr>
|
|
45
45
|
<td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="Context menu"/></td>
|
|
46
|
-
<td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / delete empty group / customize; session & session-group rows — rename / fork / archive / customize.</td>
|
|
46
|
+
<td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — new subfolder / new workspace here / rename group (updates all descendant workspaces) / delete empty group / customize; session & session-group rows — rename / fork / archive / customize.</td>
|
|
47
47
|
</tr>
|
|
48
48
|
</table>
|
|
49
49
|
|
|
50
50
|
<table>
|
|
51
51
|
<tr>
|
|
52
52
|
<td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="Settings card"/></td>
|
|
53
|
-
<td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade); expansion state and styling persist via the dsh client store in the browser.</td>
|
|
53
|
+
<td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default); expansion state and styling persist via the dsh client store in the browser.</td>
|
|
54
54
|
</tr>
|
|
55
55
|
</table>
|
|
56
56
|
|
|
@@ -58,7 +58,8 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
58
58
|
|
|
59
59
|
- **Folder-style collapse**: clicking a workspace row collapses/expands all of its sessions (the row keeps a session counter when collapsed); session groups collapse too; search force-expands everything.
|
|
60
60
|
- **Native drag restored**: drag a workspace row above/below another to reorder (writes back the host registry order) or onto a folder row to move it into that group; drag session rows to reorder within the same workspace or onto a session group to move. Display order = host manual order, drag results are visible immediately. While dragging a workspace, merged single-chain rows (e.g. work/A) temporarily re-expand into folder rows — every level of the path becomes a drop target, and chains merge back when the drag ends.
|
|
61
|
-
- **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session, current-session highlight, official status dots (blue running ring / amber pending / green done), **active-schedule badge** (alarm clock icon for sessions with active Schedule records, matching the official rows), ungrouped-session fallback, zh/en localization.
|
|
61
|
+
- **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session (the + button always expands the workspace first so the new session is immediately visible), current-session highlight, official status dots (blue running ring / amber pending / green done), **status breathing lights** (dots hidden by collapse bubble outward: workspace/folder rows breathe on their icon in the status color — amber pending / green done, custom-glow labels breathe along; running breathes blue only on the session row itself and never relays; off-able in settings), **active-schedule badge** (alarm clock icon for sessions with active Schedule records, matching the official rows), ungrouped-session fallback, zh/en localization.
|
|
62
|
+
- **URL-aware titles**: URLs in session/workspace titles (e.g. `https://host/api/v1/x`) split the way URLs mean it — scheme://host as one segment, each path segment a level — so same-prefix URLs nest together instead of shredding into pseudo-folders.
|
|
62
63
|
- **Conversation hero too**: the empty-state “Add workspace” menu uses the same picking interaction with the same group popup.
|
|
63
64
|
|
|
64
65
|
## Install
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-better-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "侧边栏工作区层级树:名称中的 / 即虚拟分组(web/前端 · web/后端),添加工作区的目录流附所属分组弹窗,重命名即时重排层级,可新建空分组。Hierarchy tree for the DSH sidebar workspace list.",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-better-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "DSH web plugin: a hierarchical workspace tree for the sidebar. Workspace titles containing \"/\" group into virtual folders (web/前端 · web/后端); the add-workspace directory flow gains a parent-group popup; renames re-derive the tree; a new-folder button creates empty levels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/client.js
CHANGED
|
@@ -31,7 +31,6 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
'title': '工作区',
|
|
32
32
|
'search.placeholder': '搜索工作区或会话',
|
|
33
33
|
'add': '添加工作区',
|
|
34
|
-
'newFolder': '新建分组',
|
|
35
34
|
'rail.search': '搜索',
|
|
36
35
|
'rail.add': '添加工作区',
|
|
37
36
|
'empty': '暂无工作区',
|
|
@@ -57,6 +56,8 @@ window.__ModuleLoader__.load({
|
|
|
57
56
|
'menu.delete': '删除',
|
|
58
57
|
'menu.fork': '分叉',
|
|
59
58
|
'menu.archive': '归档',
|
|
59
|
+
'menu.newSubfolder': '新增子分组',
|
|
60
|
+
'menu.newSubWorkspace': '新增子工作区',
|
|
60
61
|
'menu.renameFolder': '重命名分组',
|
|
61
62
|
'menu.removeFolder': '删除分组',
|
|
62
63
|
'menu.renameSgroup': '重命名会话分组',
|
|
@@ -66,6 +67,8 @@ window.__ModuleLoader__.load({
|
|
|
66
67
|
'settings.collapse': '收起',
|
|
67
68
|
'settings.compactChains': '单链分组折叠显示',
|
|
68
69
|
'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
|
|
70
|
+
'settings.statusPulse': '状态呼吸灯',
|
|
71
|
+
'settings.statusPulse.hint': '被折叠藏起的状态灯(完成绿 / 运行蓝 / 待交互琥珀)沿层级向外冒泡:工作区与分组行以图标呼吸发光(颜色随状态,自定义过发光的标题一起呼吸),会话分组行显示呼吸状态灯;默认开启,可在此关闭。',
|
|
69
72
|
'custom.title': '自定义外观',
|
|
70
73
|
'custom.color': '颜色',
|
|
71
74
|
'custom.glow': '发光',
|
|
@@ -117,7 +120,6 @@ window.__ModuleLoader__.load({
|
|
|
117
120
|
'title': 'Workspaces',
|
|
118
121
|
'search.placeholder': 'Search workspaces or sessions',
|
|
119
122
|
'add': 'Add workspace',
|
|
120
|
-
'newFolder': 'New folder',
|
|
121
123
|
'rail.search': 'Search',
|
|
122
124
|
'rail.add': 'Add workspace',
|
|
123
125
|
'empty': 'No workspaces yet',
|
|
@@ -143,6 +145,8 @@ window.__ModuleLoader__.load({
|
|
|
143
145
|
'menu.delete': 'Delete',
|
|
144
146
|
'menu.fork': 'Fork',
|
|
145
147
|
'menu.archive': 'Archive',
|
|
148
|
+
'menu.newSubfolder': 'New subfolder',
|
|
149
|
+
'menu.newSubWorkspace': 'New workspace here',
|
|
146
150
|
'menu.renameFolder': 'Rename folder',
|
|
147
151
|
'menu.removeFolder': 'Delete folder',
|
|
148
152
|
'menu.renameSgroup': 'Rename session group',
|
|
@@ -152,6 +156,8 @@ window.__ModuleLoader__.load({
|
|
|
152
156
|
'settings.collapse': 'Collapse',
|
|
153
157
|
'settings.compactChains': 'Merge single-child chains',
|
|
154
158
|
'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. Chains re-expand into folder rows while you drag a workspace, so it can drop into any level. State and custom styling persist in this browser.',
|
|
159
|
+
'settings.statusPulse': 'Status breathing light',
|
|
160
|
+
'settings.statusPulse.hint': 'Status dots hidden by collapse (done green / running blue / pending amber) bubble outward: workspace and folder rows breathe on their icon in the status color (custom-glow labels breathe along), session-group rows show a breathing dot; on by default, turn it off here.',
|
|
155
161
|
'custom.title': 'Customize',
|
|
156
162
|
'custom.color': 'Color',
|
|
157
163
|
'custom.glow': 'Glow',
|
|
@@ -210,8 +216,47 @@ window.__ModuleLoader__.load({
|
|
|
210
216
|
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
|
|
211
217
|
return i === -1 ? s : s.slice(i + 1)
|
|
212
218
|
}
|
|
213
|
-
|
|
214
|
-
|
|
219
|
+
/**
|
|
220
|
+
* URL-aware segment splitting for titles. A bare "/" split shreds URLs
|
|
221
|
+
* ("https:" → empty → host → path...), so when a "://" shows up the tail is
|
|
222
|
+
* parsed as a URL and split the way URLs mean it: scheme://host is ONE
|
|
223
|
+
* segment (the authority is not a hierarchy level), each pathname segment
|
|
224
|
+
* is a level, and query/hash glue to the last segment. Consequences:
|
|
225
|
+
* several sessions sharing a URL prefix (".../v1/m", ".../v1/n") nest
|
|
226
|
+
* under the same derived folder instead of piling up as long flat rows,
|
|
227
|
+
* while "docs/see https://x/a/b" keeps the plain "docs" head. Titles whose
|
|
228
|
+
* URL-looking tail does not parse stay opaque from the last "/" before the
|
|
229
|
+
* scheme marker. Grouping is a pure projection of titles, so sessions that
|
|
230
|
+
* were previously shredded into pseudo-folders re-flow on reload.
|
|
231
|
+
*/
|
|
232
|
+
const urlSegsOf = (raw) => {
|
|
233
|
+
let u = null
|
|
234
|
+
try { u = new URL(raw) } catch { return null }
|
|
235
|
+
const host = u.protocol + '//' + u.host
|
|
236
|
+
const pathSegs = u.pathname.split('/').filter(Boolean)
|
|
237
|
+
const tail = (u.search || '') + (u.hash || '')
|
|
238
|
+
if (pathSegs.length === 0) return [host + tail]
|
|
239
|
+
if (tail !== '') pathSegs[pathSegs.length - 1] += tail
|
|
240
|
+
return [host].concat(pathSegs)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const splitTitleSegs = (title) => {
|
|
244
|
+
const s = String(title || '')
|
|
245
|
+
const schemeAt = s.indexOf('://')
|
|
246
|
+
if (schemeAt === -1) return s.split('/').map(x => x.trim()).filter(Boolean)
|
|
247
|
+
let start = schemeAt
|
|
248
|
+
while (start > 0 && /[a-zA-Z0-9+.-]/.test(s[start - 1])) start--
|
|
249
|
+
const head = s.slice(0, start).split('/').map(x => x.trim()).filter(Boolean)
|
|
250
|
+
const urlSegs = urlSegsOf(s.slice(start).trim())
|
|
251
|
+
if (urlSegs !== null) return head.concat(urlSegs)
|
|
252
|
+
// Not a parseable URL after all: opaque from the last "/" before the marker.
|
|
253
|
+
const cut = s.lastIndexOf('/', schemeAt)
|
|
254
|
+
const segs = (cut === -1 ? '' : s.slice(0, cut)).split('/').map(x => x.trim()).filter(Boolean)
|
|
255
|
+
const tailText = s.slice(cut + 1).trim()
|
|
256
|
+
if (tailText !== '') segs.push(tailText)
|
|
257
|
+
return segs
|
|
258
|
+
}
|
|
259
|
+
const normPath = (p) => splitTitleSegs(p).join('/')
|
|
215
260
|
|
|
216
261
|
/** Render a primitives icon by name; unknown names degrade to null, never crash. */
|
|
217
262
|
const icon = (name, size) => {
|
|
@@ -219,22 +264,6 @@ window.__ModuleLoader__.load({
|
|
|
219
264
|
return C ? E(C, { size: size || 16 }) : null
|
|
220
265
|
}
|
|
221
266
|
|
|
222
|
-
/** Folder-with-plus glyph for the new-group header button (absent from the primitives set). */
|
|
223
|
-
const FolderPlusIcon = (props) => E('svg', {
|
|
224
|
-
width: props.size || 16,
|
|
225
|
-
height: props.size || 16,
|
|
226
|
-
viewBox: '0 0 16 16',
|
|
227
|
-
fill: 'none',
|
|
228
|
-
stroke: 'currentColor',
|
|
229
|
-
strokeWidth: 1.2,
|
|
230
|
-
strokeLinecap: 'round',
|
|
231
|
-
strokeLinejoin: 'round',
|
|
232
|
-
'aria-hidden': 'true',
|
|
233
|
-
},
|
|
234
|
-
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' }),
|
|
235
|
-
E('path', { d: 'M8 7.2v3.6M6.2 9h3.6' }),
|
|
236
|
-
)
|
|
237
|
-
|
|
238
267
|
const FALLBACK_UNITS = { minutes: 'm', hours: 'h', days: 'd', months: 'mo', years: 'y' }
|
|
239
268
|
const timeLabel = (updatedAt, now, t) => {
|
|
240
269
|
if (typeof ui.relativeTime !== 'function') return ''
|
|
@@ -326,22 +355,31 @@ window.__ModuleLoader__.load({
|
|
|
326
355
|
function buildSessionTree(rows) {
|
|
327
356
|
const root = { path: '', name: '', groups: [], sessions: [] }
|
|
328
357
|
const byPath = new Map([['', root]])
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
358
|
+
// SEGMENT-driven: paths arrive as already-split segment arrays (URL-aware
|
|
359
|
+
// splitTitleSegs can yield segments containing "//", e.g. the
|
|
360
|
+
// "scheme://host" authority segment) and are joined into the path KEY
|
|
361
|
+
// verbatim — never re-split on "/", which would shred an authority
|
|
362
|
+
// segment into "https:" + "" + host.
|
|
363
|
+
const ensure = (segs) => {
|
|
364
|
+
let node = root
|
|
365
|
+
let key = ''
|
|
366
|
+
for (const seg of segs) {
|
|
367
|
+
key = key === '' ? seg : key + '/' + seg
|
|
368
|
+
let next = byPath.get(key)
|
|
369
|
+
if (!next) {
|
|
370
|
+
next = { path: key, name: seg, groups: [], sessions: [] }
|
|
371
|
+
byPath.set(key, next)
|
|
372
|
+
node.groups.push(next)
|
|
373
|
+
}
|
|
374
|
+
node = next
|
|
375
|
+
}
|
|
338
376
|
return node
|
|
339
377
|
}
|
|
340
378
|
for (const row of rows || []) {
|
|
341
379
|
const segs = splitTitleSegs(row.title)
|
|
342
380
|
const folderPath = segs.slice(0, -1).join('/')
|
|
343
381
|
const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
|
|
344
|
-
ensure(
|
|
382
|
+
ensure(segs.slice(0, -1)).sessions.push({ ...row, leaf })
|
|
345
383
|
}
|
|
346
384
|
const sortRec = (node) => {
|
|
347
385
|
node.groups.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
|
@@ -377,26 +415,32 @@ window.__ModuleLoader__.load({
|
|
|
377
415
|
function buildTree(items, explicitFolders) {
|
|
378
416
|
const root = { path: '', name: '', folders: [], workspaces: [] }
|
|
379
417
|
const byPath = new Map([['', root]])
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
418
|
+
// SEGMENT-driven, same as buildSessionTree: the joined path is only a
|
|
419
|
+
// KEY; parsing it back on "/" would break URL authority segments.
|
|
420
|
+
const ensure = (segs) => {
|
|
421
|
+
let node = root
|
|
422
|
+
let key = ''
|
|
423
|
+
for (const seg of segs) {
|
|
424
|
+
key = key === '' ? seg : key + '/' + seg
|
|
425
|
+
let next = byPath.get(key)
|
|
426
|
+
if (!next) {
|
|
427
|
+
next = { path: key, name: seg, folders: [], workspaces: [] }
|
|
428
|
+
byPath.set(key, next)
|
|
429
|
+
node.folders.push(next)
|
|
430
|
+
}
|
|
431
|
+
node = next
|
|
432
|
+
}
|
|
389
433
|
return node
|
|
390
434
|
}
|
|
391
435
|
for (const folder of explicitFolders || []) {
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
436
|
+
const segs = splitTitleSegs(normPath(folder))
|
|
437
|
+
if (segs.length > 0) ensure(segs)
|
|
394
438
|
}
|
|
395
439
|
for (const workspace of items || []) {
|
|
396
440
|
const segs = splitTitleSegs(workspace.title)
|
|
397
441
|
const folderPath = segs.slice(0, -1).join('/')
|
|
398
442
|
const leaf = segs.length > 0 ? segs[segs.length - 1] : (basename(workspace.path) || String(workspace.title || '') || String(workspace.workspaceId || ''))
|
|
399
|
-
ensure(
|
|
443
|
+
ensure(segs.slice(0, -1)).workspaces.push({
|
|
400
444
|
workspaceId: workspace.workspaceId,
|
|
401
445
|
title: String(workspace.title || ''),
|
|
402
446
|
path: String(workspace.path || ''),
|
|
@@ -418,40 +462,59 @@ window.__ModuleLoader__.load({
|
|
|
418
462
|
/**
|
|
419
463
|
* VS Code-style single-child chain compression (preference-controlled):
|
|
420
464
|
* a folder level holding exactly ONE child and nothing else merges into a
|
|
421
|
-
* single display row
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
465
|
+
* single display row; a chain ending in one workspace becomes that
|
|
466
|
+
* workspace row with the merged label. Only presentation changes; the
|
|
467
|
+
* underlying workspace/title data is untouched.
|
|
468
|
+
*
|
|
469
|
+
* The merged label is the chain RELATIVE to the first un-compressed
|
|
470
|
+
* ancestor (VS Code explorer behaviour): a chain a/b/c holding only
|
|
471
|
+
* workspace W shows "a/b/c/W" at the root, but the same chain nested
|
|
472
|
+
* inside a populated folder "a" shows "b/c/W". Labels are therefore
|
|
473
|
+
* assembled from RELATIVE segments (segs + pure leaf name) and only
|
|
474
|
+
* materialised into a display node at the chain's top — the pre-0.8 code
|
|
475
|
+
* prefixed each recursion level with the FULL node.path, so nested chains
|
|
476
|
+
* rendered duplicated prefixes like "1/2/1/2/3/A".
|
|
425
477
|
*/
|
|
478
|
+
const materializeChain = (chain) => chain.kind === 'ws'
|
|
479
|
+
? {
|
|
480
|
+
kind: 'ws',
|
|
481
|
+
path: chain.path,
|
|
482
|
+
workspace: { ...chain.workspace, leaf: chain.segs.join('/') + '/' + chain.pure, title: chain.workspace.title, folderPath: '' },
|
|
483
|
+
folders: [],
|
|
484
|
+
workspaces: [],
|
|
485
|
+
}
|
|
486
|
+
: { kind: 'folder', path: chain.path, name: chain.segs.join('/'), folders: chain.folders, workspaces: chain.workspaces }
|
|
487
|
+
|
|
426
488
|
function compressTree(node) {
|
|
427
489
|
const folders = (node.folders || []).map(compressTree)
|
|
428
490
|
const workspaces = node.workspaces || []
|
|
429
491
|
if (workspaces.length === 0 && folders.length === 1) {
|
|
492
|
+
// Continue the chain upward: one more relative segment in front of
|
|
493
|
+
// whatever the child chain accumulated. path stays the DEEPEST full
|
|
494
|
+
// path (expansion identity); the label is joined only at the top.
|
|
430
495
|
const child = folders[0]
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
dropPath: child.path,
|
|
440
|
-
}
|
|
496
|
+
return {
|
|
497
|
+
kind: child.kind,
|
|
498
|
+
path: child.path,
|
|
499
|
+
segs: [node.name].concat(child.segs || []),
|
|
500
|
+
pure: child.pure,
|
|
501
|
+
workspace: child.workspace,
|
|
502
|
+
folders: child.folders,
|
|
503
|
+
workspaces: child.workspaces,
|
|
441
504
|
}
|
|
442
|
-
return { kind: 'folder', path: child.path, name: node.name + '/' + child.name, folders: child.folders || [], workspaces: child.workspaces || [], dropPath: child.path || node.path }
|
|
443
505
|
}
|
|
444
506
|
if (folders.length === 0 && workspaces.length === 1) {
|
|
445
|
-
|
|
446
|
-
return { kind: 'ws', path: node.path, workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' }, folders: [], workspaces: [], dropPath: node.path }
|
|
507
|
+
return { kind: 'ws', path: node.path, segs: [node.name], pure: workspaces[0].leaf, workspace: workspaces[0], folders: [], workspaces: [] }
|
|
447
508
|
}
|
|
448
|
-
|
|
509
|
+
// Chain top (multiple children, or a mix): children are materialised
|
|
510
|
+
// relative to this node; this node itself keeps its single name.
|
|
511
|
+
return { kind: 'folder', path: node.path, segs: [node.name], folders: folders.map(materializeChain), workspaces }
|
|
449
512
|
}
|
|
450
513
|
|
|
451
514
|
/* ============================== styles ============================ */
|
|
452
515
|
|
|
453
516
|
const CSS_TEXT = [
|
|
454
|
-
'.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
517
|
+
'.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;position:relative;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
455
518
|
'.bw-header{display:flex;align-items:center;gap:2px;padding:10px 10px 4px;flex:none}',
|
|
456
519
|
'.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}',
|
|
457
520
|
'.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}',
|
|
@@ -550,6 +613,10 @@ window.__ModuleLoader__.load({
|
|
|
550
613
|
'.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
551
614
|
'.bw-preview-icon svg{width:18px;height:18px}',
|
|
552
615
|
'.bw-preview-label{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:18px}',
|
|
616
|
+
'.bw-pulse{animation:bw-breathe 1.8s ease-in-out infinite}',
|
|
617
|
+
'@keyframes bw-breathe{0%,100%{filter:drop-shadow(0 0 1px var(--bw-pulse-color));opacity:.55}50%{filter:drop-shadow(0 0 6px var(--bw-pulse-color));opacity:1}}',
|
|
618
|
+
'.bw-pulse-text{animation:bw-breathe-text 1.8s ease-in-out infinite}',
|
|
619
|
+
'@keyframes bw-breathe-text{0%,100%{text-shadow:0 0 1px var(--bw-pulse-color);opacity:.65}50%{text-shadow:0 0 7px var(--bw-pulse-color);opacity:1}}',
|
|
553
620
|
].join('')
|
|
554
621
|
|
|
555
622
|
const StyleNode = () => E('style', null, CSS_TEXT)
|
|
@@ -670,6 +737,7 @@ window.__ModuleLoader__.load({
|
|
|
670
737
|
*/
|
|
671
738
|
function BetterFlow(props) {
|
|
672
739
|
const { open, busy, onPicked, onCancel, onError, createWorkspace, renameWorkspace, pickDirectory, useWorkspaces, useStore, t } = props
|
|
740
|
+
const initialParent = props.initialParent || ''
|
|
673
741
|
const actions = props.actions
|
|
674
742
|
const [phase, setPhase] = React.useState('idle') // idle | picking | picked | submitting
|
|
675
743
|
const [pickedPath, setPickedPath] = React.useState('')
|
|
@@ -694,7 +762,7 @@ window.__ModuleLoader__.load({
|
|
|
694
762
|
if (!alive) return
|
|
695
763
|
if (!path) { onCancel(); return }
|
|
696
764
|
setPickedPath(String(path))
|
|
697
|
-
setParentInput(
|
|
765
|
+
setParentInput(initialParent)
|
|
698
766
|
setPhase('picked')
|
|
699
767
|
})
|
|
700
768
|
.catch((reason) => {
|
|
@@ -797,11 +865,17 @@ window.__ModuleLoader__.load({
|
|
|
797
865
|
.map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
|
|
798
866
|
.join('')
|
|
799
867
|
|
|
800
|
-
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, t }) {
|
|
868
|
+
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, t }) {
|
|
801
869
|
const total = countWorkspaces(node)
|
|
870
|
+
const iconEl = iconOf(iconMode, expanded)
|
|
871
|
+
// Hidden status dots breathe on the icon; with the icon hidden (mode
|
|
872
|
+
// "none" or a missing primitive) the official StateDot stands in.
|
|
873
|
+
const iconChild = pulse
|
|
874
|
+
? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
|
|
875
|
+
: iconEl
|
|
802
876
|
return E('div', {
|
|
803
877
|
className: cls('bw-row', dropInto && 'bw-drop-into'),
|
|
804
|
-
style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
|
|
878
|
+
style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
|
|
805
879
|
onClick: onToggle,
|
|
806
880
|
onContextMenu: onContextMenu,
|
|
807
881
|
role: 'treeitem',
|
|
@@ -809,16 +883,21 @@ window.__ModuleLoader__.load({
|
|
|
809
883
|
...(dragEvents || {}),
|
|
810
884
|
},
|
|
811
885
|
E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
812
|
-
E('span', { className: 'bw-row-icon' },
|
|
813
|
-
|
|
886
|
+
E('span', { className: 'bw-row-icon' }, iconChild),
|
|
887
|
+
// A row that already carries a custom label glow breathes in sync.
|
|
888
|
+
E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text') }, node.name),
|
|
814
889
|
total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
|
|
815
890
|
)
|
|
816
891
|
}
|
|
817
892
|
|
|
818
|
-
function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, t }) {
|
|
893
|
+
function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, pulse, t }) {
|
|
894
|
+
const iconEl = iconOf(iconMode, sessionsOpen)
|
|
895
|
+
const iconChild = pulse
|
|
896
|
+
? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
|
|
897
|
+
: iconEl
|
|
819
898
|
return E('div', {
|
|
820
899
|
className: cls('bw-row', currentInside && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
|
|
821
|
-
style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}) },
|
|
900
|
+
style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
|
|
822
901
|
onClick: onToggle,
|
|
823
902
|
onContextMenu: onContextMenu,
|
|
824
903
|
role: 'treeitem',
|
|
@@ -826,8 +905,8 @@ window.__ModuleLoader__.load({
|
|
|
826
905
|
...(dragEvents || {}),
|
|
827
906
|
},
|
|
828
907
|
E('span', { className: cls('bw-chevron', sessionsOpen && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
829
|
-
E('span', { className: 'bw-row-icon' },
|
|
830
|
-
E('span', { className: 'bw-row-label', title: workspace.title || workspace.leaf }, workspace.leaf),
|
|
908
|
+
E('span', { className: 'bw-row-icon' }, iconChild),
|
|
909
|
+
E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text'), title: workspace.title || workspace.leaf }, workspace.leaf),
|
|
831
910
|
count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
|
|
832
911
|
E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
|
|
833
912
|
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
|
|
@@ -835,16 +914,53 @@ window.__ModuleLoader__.load({
|
|
|
835
914
|
)
|
|
836
915
|
}
|
|
837
916
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
if (
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
917
|
+
// Official status priority: pending interaction > running > running
|
|
918
|
+
// subagents > completed reminder; idle rows show no dot at all.
|
|
919
|
+
const sessionStateOf = (row) => {
|
|
920
|
+
if (!row) return null
|
|
921
|
+
if (row.pending === 'approval' || row.pending === 'plan-review' || row.pending === 'question') return 'warning'
|
|
922
|
+
if (row.running || row.subagents > 0) return 'ongoing'
|
|
923
|
+
if (row.completed) return 'done'
|
|
924
|
+
return null
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Status breathing light (preference-controlled, default ON): status dots
|
|
929
|
+
* hidden by a collapse bubble outward to the nearest visible container
|
|
930
|
+
* row. Workspace/folder rows breathe on their icon (drop-shadow glow in
|
|
931
|
+
* the status color; a hidden/no icon falls back to the official StateDot);
|
|
932
|
+
* session-group rows carry a breathing StateDot in front of the label.
|
|
933
|
+
* Colors mirror the official dots: warning amber > ongoing blue > done
|
|
934
|
+
* green, so the glow always matches the lamp it relays.
|
|
935
|
+
*/
|
|
936
|
+
const PULSE_RANK = { warning: 3, ongoing: 2, done: 1 }
|
|
937
|
+
const PULSE_COLORS = { warning: '#d29922', ongoing: '#5b8def', done: '#3fb950' }
|
|
938
|
+
const pulseRank = (state) => (state ? (PULSE_RANK[state] || 0) : 0)
|
|
939
|
+
|
|
940
|
+
function PulseGlow({ state, children }) {
|
|
941
|
+
return E('span', {
|
|
942
|
+
className: 'bw-pulse',
|
|
943
|
+
style: { '--bw-pulse-color': PULSE_COLORS[state] || PULSE_COLORS.ongoing },
|
|
944
|
+
}, children)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// RUNNING deliberately stays on the session row itself (its official ring
|
|
948
|
+
// breathes blue there) and does NOT bubble outward — the user found the
|
|
949
|
+
// ongoing relay noisy. Only pending-amber and done-green propagate.
|
|
950
|
+
const relayStateOf = (row) => {
|
|
951
|
+
const s = sessionStateOf(row)
|
|
952
|
+
return s === 'ongoing' ? null : s
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, breathing, t }) {
|
|
956
|
+
const state = sessionStateOf(node)
|
|
957
|
+
const status = state === null ? null : {
|
|
958
|
+
state,
|
|
959
|
+
title: state === 'warning'
|
|
960
|
+
? t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending))
|
|
961
|
+
: state === 'ongoing'
|
|
962
|
+
? (node.running ? t('status.running') : t('status.subagents', { n: node.subagents }))
|
|
963
|
+
: t('status.completed'),
|
|
848
964
|
}
|
|
849
965
|
return E('div', {
|
|
850
966
|
className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
|
|
@@ -856,7 +972,11 @@ window.__ModuleLoader__.load({
|
|
|
856
972
|
},
|
|
857
973
|
E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
|
|
858
974
|
status && typeof ui.StateDot === 'function'
|
|
859
|
-
?
|
|
975
|
+
? (state === 'ongoing' && breathing
|
|
976
|
+
// Running breathes blue ON the session row only (never relays);
|
|
977
|
+
// the settings toggle covers this breathing too.
|
|
978
|
+
? E(PulseGlow, { state: 'ongoing' }, E(ui.StateDot, { state: status.state, size: 10 }))
|
|
979
|
+
: E(ui.StateDot, { state: status.state, size: 10 }))
|
|
860
980
|
: E('span', { className: 'bw-dot' }),
|
|
861
981
|
),
|
|
862
982
|
E('span', { className: 'bw-row-label' }, node.leaf || node.title),
|
|
@@ -872,7 +992,7 @@ window.__ModuleLoader__.load({
|
|
|
872
992
|
* titles). Deliberately NOT styled like a workspace folder — no folder
|
|
873
993
|
* icon, tertiary color — so a session level never reads as a workspace.
|
|
874
994
|
*/
|
|
875
|
-
function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, t }) {
|
|
995
|
+
function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, pulse, t }) {
|
|
876
996
|
return E('div', {
|
|
877
997
|
className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
|
|
878
998
|
style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
|
|
@@ -883,6 +1003,9 @@ window.__ModuleLoader__.load({
|
|
|
883
1003
|
...(dragEvents || {}),
|
|
884
1004
|
},
|
|
885
1005
|
E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
|
|
1006
|
+
// Session groups have no icon slot: a collapsed group with hidden
|
|
1007
|
+
// status relays through a breathing official StateDot instead.
|
|
1008
|
+
pulse ? E(PulseGlow, { state: pulse }, typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null) : null,
|
|
886
1009
|
E('span', { className: 'bw-row-label' }, name),
|
|
887
1010
|
count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
|
|
888
1011
|
)
|
|
@@ -1075,6 +1198,7 @@ window.__ModuleLoader__.load({
|
|
|
1075
1198
|
function BetterWorkspaceSettings({ useStore, actions, t }) {
|
|
1076
1199
|
const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1077
1200
|
const compactChains = prefs.compactChains !== false
|
|
1201
|
+
const statusPulse = prefs.statusPulse !== false
|
|
1078
1202
|
return E('div', { className: 'bw-settings' },
|
|
1079
1203
|
StyleNode(),
|
|
1080
1204
|
E('div', { className: 'bw-setting-row' },
|
|
@@ -1089,6 +1213,18 @@ window.__ModuleLoader__.load({
|
|
|
1089
1213
|
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1090
1214
|
),
|
|
1091
1215
|
E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
|
|
1216
|
+
E('div', { className: 'bw-setting-row', style: { marginTop: 10 } },
|
|
1217
|
+
E('div', { className: 'bw-setting-label' }, t('settings.statusPulse')),
|
|
1218
|
+
E('button', {
|
|
1219
|
+
type: 'button',
|
|
1220
|
+
role: 'switch',
|
|
1221
|
+
'aria-checked': statusPulse,
|
|
1222
|
+
'aria-label': t('settings.statusPulse'),
|
|
1223
|
+
className: cls('bw-switch', statusPulse && 'bw-switch-on'),
|
|
1224
|
+
onClick: () => { actions.setPref('statusPulse', !statusPulse) },
|
|
1225
|
+
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1226
|
+
),
|
|
1227
|
+
E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
|
|
1092
1228
|
)
|
|
1093
1229
|
}
|
|
1094
1230
|
|
|
@@ -1146,12 +1282,14 @@ window.__ModuleLoader__.load({
|
|
|
1146
1282
|
const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1147
1283
|
const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
|
|
1148
1284
|
const compactChains = prefsMap.compactChains !== false
|
|
1285
|
+
const statusPulse = prefsMap.statusPulse !== false
|
|
1149
1286
|
const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
|
|
1150
1287
|
const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
|
|
1151
1288
|
|
|
1152
1289
|
const [query, setQuery] = React.useState('')
|
|
1153
1290
|
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
1154
1291
|
const [flowOpen, setFlowOpen] = React.useState(false)
|
|
1292
|
+
const [flowParent, setFlowParent] = React.useState('') // parent path prefill for the add-workspace flow (context menu entry)
|
|
1155
1293
|
const [dialog, setDialog] = React.useState(null) // { kind, ... }
|
|
1156
1294
|
const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
|
|
1157
1295
|
const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
|
|
@@ -1226,7 +1364,7 @@ window.__ModuleLoader__.load({
|
|
|
1226
1364
|
const tree = React.useMemo(() => {
|
|
1227
1365
|
const built = buildTree(items, storeFolders)
|
|
1228
1366
|
if (!compactChains || draggingWorkspace) return built
|
|
1229
|
-
return { ...built, folders: built.folders.map(compressTree), workspaces: built.workspaces }
|
|
1367
|
+
return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
|
|
1230
1368
|
}, [items, storeFolders, compactChains, draggingWorkspace])
|
|
1231
1369
|
|
|
1232
1370
|
const sessionsOf = (workspace) => {
|
|
@@ -1280,6 +1418,45 @@ window.__ModuleLoader__.load({
|
|
|
1280
1418
|
const searched = normalizedQuery ? searchAgent(tree) : null
|
|
1281
1419
|
const searching = normalizedQuery !== ''
|
|
1282
1420
|
|
|
1421
|
+
/* --------------------- status breathing relay -------------------- */
|
|
1422
|
+
|
|
1423
|
+
// Highest-priority status across a whole session subtree (groups + rows).
|
|
1424
|
+
const nodePulseOf = (sessionNode) => {
|
|
1425
|
+
let best = null
|
|
1426
|
+
for (const group of sessionNode.groups || []) {
|
|
1427
|
+
const s = nodePulseOf(group)
|
|
1428
|
+
if (pulseRank(s) > pulseRank(best)) best = s
|
|
1429
|
+
}
|
|
1430
|
+
for (const row of sessionNode.sessions || []) {
|
|
1431
|
+
const s = relayStateOf(row)
|
|
1432
|
+
if (pulseRank(s) > pulseRank(best)) best = s
|
|
1433
|
+
}
|
|
1434
|
+
return best
|
|
1435
|
+
}
|
|
1436
|
+
// A COLLAPSED workspace row relays its whole session tree; an open row
|
|
1437
|
+
// shows the real dots (deeper collapsed groups relay on their own rows).
|
|
1438
|
+
const wsPulseOf = (workspace) => (!statusPulse || searching || sessionsOpenOf(workspace.workspaceId))
|
|
1439
|
+
? null
|
|
1440
|
+
: nodePulseOf(buildSessionTree(sessionsOf(workspace)))
|
|
1441
|
+
// A collapsed FOLDER hides everything below — including open workspaces —
|
|
1442
|
+
// so its aggregation ignores inner expansion states entirely.
|
|
1443
|
+
const wsAllPulseOf = (workspace) => (!statusPulse || searching)
|
|
1444
|
+
? null
|
|
1445
|
+
: nodePulseOf(buildSessionTree(sessionsOf(workspace)))
|
|
1446
|
+
const folderPulseOf = (node) => {
|
|
1447
|
+
if (node.kind === 'ws') return wsAllPulseOf(node.workspace)
|
|
1448
|
+
let best = null
|
|
1449
|
+
for (const child of node.folders) {
|
|
1450
|
+
const s = folderPulseOf(child)
|
|
1451
|
+
if (pulseRank(s) > pulseRank(best)) best = s
|
|
1452
|
+
}
|
|
1453
|
+
for (const w of node.workspaces) {
|
|
1454
|
+
const s = wsAllPulseOf(w)
|
|
1455
|
+
if (pulseRank(s) > pulseRank(best)) best = s
|
|
1456
|
+
}
|
|
1457
|
+
return best
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1283
1460
|
const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
|
|
1284
1461
|
const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
|
|
1285
1462
|
const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
|
|
@@ -1566,10 +1743,19 @@ window.__ModuleLoader__.load({
|
|
|
1566
1743
|
return null
|
|
1567
1744
|
}
|
|
1568
1745
|
|
|
1569
|
-
const submitFolderNew = (rawPath) => {
|
|
1570
|
-
const
|
|
1571
|
-
|
|
1572
|
-
if (
|
|
1746
|
+
const submitFolderNew = (parentPath, rawPath) => {
|
|
1747
|
+
const parent = normPath(parentPath)
|
|
1748
|
+
const sub = normPath(rawPath)
|
|
1749
|
+
if (sub === '') { setErrorText(t('folder.error.empty')); return }
|
|
1750
|
+
const p = parent !== '' ? parent + '/' + sub : sub
|
|
1751
|
+
// Existence check covers explicit folders AND every folder derived
|
|
1752
|
+
// from workspace titles, so "already exists" means either kind.
|
|
1753
|
+
const derived = new Set()
|
|
1754
|
+
for (const w of items || []) {
|
|
1755
|
+
const segs = splitTitleSegs(w.title)
|
|
1756
|
+
for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
|
|
1757
|
+
}
|
|
1758
|
+
if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
|
|
1573
1759
|
actions.addFolder(p)
|
|
1574
1760
|
setDialog(null)
|
|
1575
1761
|
}
|
|
@@ -1634,6 +1820,7 @@ window.__ModuleLoader__.load({
|
|
|
1634
1820
|
name: group.name,
|
|
1635
1821
|
depth,
|
|
1636
1822
|
expanded: open,
|
|
1823
|
+
pulse: (statusPulse && !open) ? nodePulseOf(group) : null,
|
|
1637
1824
|
count: countSessionTree(group),
|
|
1638
1825
|
onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
|
|
1639
1826
|
onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
|
|
@@ -1659,10 +1846,11 @@ window.__ModuleLoader__.load({
|
|
|
1659
1846
|
dropHalf: workspaceId ? sessDropHalf(session.id) : null,
|
|
1660
1847
|
dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
|
|
1661
1848
|
custStyle: rowStyleOf('session:' + session.id),
|
|
1849
|
+
breathing: statusPulse,
|
|
1662
1850
|
t,
|
|
1663
1851
|
})
|
|
1664
1852
|
|
|
1665
|
-
const renderWorkspaceEntry = (entry, depth) => {
|
|
1853
|
+
const renderWorkspaceEntry = (entry, depth, pulse) => {
|
|
1666
1854
|
const { workspace } = entry
|
|
1667
1855
|
const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
|
|
1668
1856
|
const rows = [E(WorkspaceRow, {
|
|
@@ -1673,12 +1861,15 @@ window.__ModuleLoader__.load({
|
|
|
1673
1861
|
sessionsOpen: searching ? true : sessionsOpenOf(workspace.workspaceId),
|
|
1674
1862
|
currentInside: !!(list && list.current && (workspace.sessionIds || []).includes(list.current)),
|
|
1675
1863
|
onToggle: () => { if (!searching) actions.setSessionsExpanded(workspace.workspaceId, !sessionsOpenOf(workspace.workspaceId)) },
|
|
1676
|
-
|
|
1864
|
+
// Starting a session force-expands the workspace row: the user must
|
|
1865
|
+
// SEE the new session appear, even if the row was collapsed.
|
|
1866
|
+
onStart: () => { actions.setSessionsExpanded(workspace.workspaceId, true); startSession(workspace.workspaceId) },
|
|
1677
1867
|
onContextMenu: (e) => openCtx('workspace', workspace, e),
|
|
1678
1868
|
dropHalf: wsDropHalf(workspace.workspaceId),
|
|
1679
1869
|
dragEvents: workspaceDragEvents(workspace),
|
|
1680
1870
|
custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
|
|
1681
1871
|
iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
|
|
1872
|
+
pulse,
|
|
1682
1873
|
t,
|
|
1683
1874
|
})]
|
|
1684
1875
|
rows.push(...renderSessionTree(workspace, depth + 1))
|
|
@@ -1686,7 +1877,7 @@ window.__ModuleLoader__.load({
|
|
|
1686
1877
|
}
|
|
1687
1878
|
|
|
1688
1879
|
const renderPlainFolder = (node, depth) => {
|
|
1689
|
-
if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
|
|
1880
|
+
if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth, wsPulseOf(node.workspace))
|
|
1690
1881
|
const expanded = searching || folderExpanded(node.path)
|
|
1691
1882
|
const rows = [E(FolderRow, {
|
|
1692
1883
|
key: 'f-' + node.path,
|
|
@@ -1699,11 +1890,12 @@ window.__ModuleLoader__.load({
|
|
|
1699
1890
|
dragEvents: folderDropEvents(node.path),
|
|
1700
1891
|
custStyle: rowStyleOf('folder:' + node.path),
|
|
1701
1892
|
iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
|
|
1893
|
+
pulse: expanded ? null : folderPulseOf(node),
|
|
1702
1894
|
t,
|
|
1703
1895
|
})]
|
|
1704
1896
|
if (expanded) {
|
|
1705
1897
|
for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
|
|
1706
|
-
for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1))
|
|
1898
|
+
for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
|
|
1707
1899
|
}
|
|
1708
1900
|
return rows
|
|
1709
1901
|
}
|
|
@@ -1736,7 +1928,7 @@ window.__ModuleLoader__.load({
|
|
|
1736
1928
|
}
|
|
1737
1929
|
} else {
|
|
1738
1930
|
for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
|
|
1739
|
-
for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0))
|
|
1931
|
+
for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
|
|
1740
1932
|
if (ungrouped.length > 0) {
|
|
1741
1933
|
bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
|
|
1742
1934
|
for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
|
|
@@ -1752,7 +1944,11 @@ window.__ModuleLoader__.load({
|
|
|
1752
1944
|
const ctxItems = () => {
|
|
1753
1945
|
if (ctx === null) return []
|
|
1754
1946
|
if (ctx.kind === 'folder') {
|
|
1755
|
-
const items = [
|
|
1947
|
+
const items = [
|
|
1948
|
+
{ id: 'new-subfolder', label: t('menu.newSubfolder') },
|
|
1949
|
+
{ id: 'new-subworkspace', label: t('menu.newSubWorkspace') },
|
|
1950
|
+
{ id: 'rename-folder', label: t('menu.renameFolder') },
|
|
1951
|
+
]
|
|
1756
1952
|
if (storeFolders.includes(ctx.payload.path)) items.push({ id: 'remove-folder', label: t('menu.removeFolder'), danger: true })
|
|
1757
1953
|
items.push({ sep: true })
|
|
1758
1954
|
items.push({ id: 'customize', label: t('custom.title') })
|
|
@@ -1790,7 +1986,9 @@ window.__ModuleLoader__.load({
|
|
|
1790
1986
|
}
|
|
1791
1987
|
const { kind, payload } = current
|
|
1792
1988
|
setCtx(null)
|
|
1793
|
-
if (kind === 'folder' && id === '
|
|
1989
|
+
if (kind === 'folder' && id === 'new-subfolder') setDialog({ kind: 'folder-new', parentPath: payload.path })
|
|
1990
|
+
else if (kind === 'folder' && id === 'new-subworkspace') { setFlowParent(payload.path); setFlowOpen(true) }
|
|
1991
|
+
else if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
|
|
1794
1992
|
else if (kind === 'folder' && id === 'remove-folder') setDialog({ kind: 'folder-delete', path: payload.path })
|
|
1795
1993
|
else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
|
|
1796
1994
|
else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
|
|
@@ -1810,7 +2008,7 @@ window.__ModuleLoader__.load({
|
|
|
1810
2008
|
return E('div', { className: 'bw-rail' },
|
|
1811
2009
|
StyleNode(),
|
|
1812
2010
|
E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.search'), onClick: () => { expandSidebar(); setSearchOpen(true) } }, icon('IconSearchOutline16', 18)),
|
|
1813
|
-
E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
|
|
2011
|
+
E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
|
|
1814
2012
|
)
|
|
1815
2013
|
}
|
|
1816
2014
|
|
|
@@ -1854,8 +2052,8 @@ window.__ModuleLoader__.load({
|
|
|
1854
2052
|
key: 'folder-new',
|
|
1855
2053
|
title: t('folder.new.title'),
|
|
1856
2054
|
hint: t('folder.new.hint'),
|
|
1857
|
-
initial: '',
|
|
1858
|
-
onConfirm: submitFolderNew,
|
|
2055
|
+
initial: dialog.parentPath ? dialog.parentPath + '/' : '',
|
|
2056
|
+
onConfirm: (v) => submitFolderNew(dialog.parentPath || '', v),
|
|
1859
2057
|
onClose: () => setDialog(null),
|
|
1860
2058
|
t,
|
|
1861
2059
|
})
|
|
@@ -1904,13 +2102,13 @@ window.__ModuleLoader__.load({
|
|
|
1904
2102
|
onBlur: () => { if (query === '') setSearchOpen(false) },
|
|
1905
2103
|
}) : null,
|
|
1906
2104
|
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
|
|
1907
|
-
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('
|
|
1908
|
-
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => setFlowOpen(true) }, icon('IconProjectAddOutline16')),
|
|
2105
|
+
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => { setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16')),
|
|
1909
2106
|
),
|
|
1910
2107
|
E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
|
|
1911
2108
|
E(BetterFlow, {
|
|
1912
2109
|
open: flowOpen,
|
|
1913
2110
|
busy: false,
|
|
2111
|
+
initialParent: flowParent,
|
|
1914
2112
|
onPicked: flowOwner.onPicked,
|
|
1915
2113
|
onCancel: flowOwner.onCancel,
|
|
1916
2114
|
onError: flowOwner.onError,
|