dsh-better-workspace 0.35.1 → 0.36.8
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 +1 -1
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +831 -73
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
### 其余亮点
|
|
70
70
|
|
|
71
71
|
- **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标;角标 v0.34.0 起默认隐藏、对齐官方,设置卡「显示会话数」可开);会话分组同样可收起;搜索时自动全部展开。
|
|
72
|
-
- **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序)
|
|
72
|
+
- **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行中带则移动进该分组(改写名称前缀);**工作区分组行同样可拖拽(v0.36.0 起,三带悬停)**——拖到分组行时行分三个落区:上下 **30% 边带**=与该组**排序**(拖完立即重排、刷新后保留),**中带**=整组移入(组成员逐个改写名称前缀,与「重命名分组」同一语义;拖到自身/自己的子孙组上会被拒绝);**组行与工作区行同处一条混合序列(v0.36.5 起)**:一个层级里的行顺序只有一份(组行与裸工作区行交错),工作区可以拖到分组行的边带插到该组前面/后面,分组也可以拖到工作区行前面/后面——不再有「组永远排在裸工作区之前」的限制;拖到**另一层**的边带是**放置**(v0.36.6):工作区离开原组(或进入目标层)并插到落点,「插到第一个」画哪落哪(跨磁盘层不放行——磁盘层是文件系统事实);**拖入黑框统一(v0.36.7)**:工作区行拖到分组行中带 = 改名前缀进组(黑框反馈);组行拖到**它所属的磁盘层工作区行 / 直接父级分组行**中带 = 整组「放入」该容器、排到该层末尾(标题与磁盘位置都不动,同一套黑框视觉)——排序走边带、放入走中带,两类操作视觉上彻底分开;**撑开让位(v0.36.4)**:进入排序边带立刻把目标行撑开一行(工作区行与分组行同一套视觉),判定按**行未撑开时的原始位置**算——指针停在槽空出的新区里保持当前方向不闪烁;槽已打开的行只按原始中线二选一(上半=插在前,下半=插在后,**不会翻成「移入」**),三带里的中带 INTO 只在首次进入时决定,随时可改主意;会话行可拖拽——同组内拖到另一会话上/下即调整顺序,拖到**别组的会话行**会把名字前缀改写到那个组(拖到根层的行 = 移出分组),拖到会话子分组行即移动进该分组,拖到**自己的工作区行**或**树里任何组外的地方**(含空白区域)同样移出分组 —— 拖拽期间工作区行整行高亮、树底部有一条淡虚线提示这个出口;带分组前缀的会话行右键还多一项「移出分组」。显示顺序 = 宿主手动序(分组行 = 父容器下的持久化组序),拖拽结果立即生效。**磁盘层是文件系统事实,不随拖拽改变**:跨磁盘层不放行(边带不接管光标、不画落点线),同磁盘层内按落点锚定。拖拽期间树形保持不变(v0.36.4):单链合并行不再临时展开、归组成功也不自动展开目标组——指针下的行不会突然换人,展开状态只在点击时改变。
|
|
73
73
|
- **不丢原生能力**:会话行(打开/重命名/分叉/归档;**双击标题**重命名,v0.34.0 起;**右键是唯一菜单入口**——行内「⋯」已退役,悬停只留快捷钮,工作区行同样只留新会话钮,v0.35.1 起)、**官方行样式逐项对齐**(v0.34.0 起:恒定 16px 状态槽——运行转圈不再挤动标题、悬停 800ms 弹官方 HoverCard(标题/路径/时间/状态,可复制)、超长标题悬停匀速滚动、活动链工作区文件夹图标着蓝、拖放指示用官方斜纹图案)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标;v0.23.0 起直接渲染官方 `sidebar.session.row.leading` 座位的占用者,与官方行同源)、未分组会话兜底、界面文案跟随 DSH 的语言设置(内置 21 种语言,含简繁中文)。
|
|
74
74
|
- **新增项自动配色 + 图标(v0.25.0,默认关闭)**:设置卡里一个开关,打开后**新创建的会话**自动分到一个内置色、**新工作区**再额外分到一个图标,懒得逐个设置的人直接开箱即用。颜色来自**定死的 13 个槽位**,每个槽位配**两色**——浅色主题用深色系、深色主题用亮色系——按「洗牌袋 + 近期回避窗口」抽取:一轮之内绝不重复,跨轮也不会立刻撞上刚出过的 3 个,连续新建几项肉眼可辨。**可读性是真达标**:每个色在**对应主题**背景下的对比度都 **≥4.5**,即 WCAG **正文级 AA**(对比度是脚本真算的,测试逐色断言;浅色主题按比纯白更暗的悬停面、深色主题按最亮的面这种保守取值)。行里存的是**槽位 id(`auto:3`)而不是色值**,所以**切换深浅主题时颜色自动换成同色系的另一套**,既不用重新设置,也不会失配。**只影响打开开关之后新建的项**:你手动设置过外观的行(存的是 hex)永不被覆盖,关闭开关也不回收已分配的样式,同一项不会被二次随机。图标从既有图标候选集里取、按宿主实际导出过滤;**图标只给工作区**——会话行的那个位置是官方状态灯与日程角标,没有图标位。
|
|
75
75
|
- **官方新语义与快捷键都接上(v0.23.0,对齐 dsh 0.1.7-rc.2)**:① 归档筛选是官方新版三态直选(**隐藏已归档** / **全部对话(显示已归档)** / **仅显示已归档**);选「仅显示已归档」时**没有归档会话的工作区整行隐藏**,空列表显示「暂无已归档会话」+「查看其他会话」一键回退;② 官方快捷键 **⌥⌘K 搜索 / ⌥⌘O 添加工作区 / ⌥⌘N 新会话**(桌面端为 ⌘K / ⌘O / ⌘N)在本插件的自绘侧栏里**真的生效**——搜索键打开并聚焦本插件的搜索框、添加键走同一个官方目录流(原生宿主弹系统选择器,browse 宿主弹应用内入口提示),按钮文案还会带上宿主当前的键位;③ 首次使用的工作区显示为本地化的「默认工作区」,不再露出磁盘目录名。
|
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.36.8",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树。Workspace tree for the DSH sidebar workspace list, nested by official disk folders with optional \"/\"-name grouping; the New Session workspace picker shows the same tree.",
|
|
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.36.8",
|
|
4
4
|
"description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树(同一份视图选项即时同步);添加工作区走官方目录流。Workspace tree for the DSH sidebar: disk-path nesting (official workspace-tree semantics), with optional \"/\"-name grouping (off by default), and the same tree in the New Session workspace picker; add-workspace rides the official directory flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/client.js
CHANGED
|
@@ -4041,6 +4041,19 @@ window.__ModuleLoader__.load({
|
|
|
4041
4041
|
return [...blanks, ...pins, ...rest]
|
|
4042
4042
|
}
|
|
4043
4043
|
|
|
4044
|
+
// Global and per-workspace recency share the same ordering contract:
|
|
4045
|
+
// current blank first, then pinned Sessions, then ordinary Sessions.
|
|
4046
|
+
// In flat mode this must run AFTER collecting every workspace and Ungrouped.
|
|
4047
|
+
const orderSessionsByRecency = (rows, currentId) => {
|
|
4048
|
+
const sorted = rows.slice().sort((a, b) => (b.updatedAt - a.updatedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
4049
|
+
const blank = currentId !== undefined && sorted.find((row) => row.id === currentId && row.blank)
|
|
4050
|
+
if (blank) {
|
|
4051
|
+
const at = sorted.indexOf(blank)
|
|
4052
|
+
if (at > 0) { sorted.splice(at, 1); sorted.unshift(blank) }
|
|
4053
|
+
}
|
|
4054
|
+
return pinnedPartition(sorted)
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4044
4057
|
const sessionTitleOf = (summary, t, rememberedTitle) => {
|
|
4045
4058
|
if (!summary) return ''
|
|
4046
4059
|
if (summary.blank) return t('session.new')
|
|
@@ -4485,7 +4498,7 @@ window.__ModuleLoader__.load({
|
|
|
4485
4498
|
return out
|
|
4486
4499
|
}
|
|
4487
4500
|
|
|
4488
|
-
function buildTree(items, groupMode, explicitWs) {
|
|
4501
|
+
function buildTree(items, groupMode, explicitWs, folderOrder) {
|
|
4489
4502
|
const list = items || []
|
|
4490
4503
|
// Issue #7: 'disk' nests by disk only, 'disk-slash' adds the name
|
|
4491
4504
|
// layer on top (the historical workspaceSlash-on shape), 'slash' runs
|
|
@@ -4564,11 +4577,76 @@ window.__ModuleLoader__.load({
|
|
|
4564
4577
|
}
|
|
4565
4578
|
ensure(segs.slice(0, -1)).workspaces.push(entry)
|
|
4566
4579
|
}
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4580
|
+
// Group order: the NAME sort is only the seed. v0.36.4 keeps a
|
|
4581
|
+
// persisted per-parent name list (folderOrder) so a dragged group
|
|
4582
|
+
// keeps its place — before this the drop committed the registry
|
|
4583
|
+
// order but the renderer re-sorted by name, which is why dragging a
|
|
4584
|
+
// group row looked like a no-op. Names absent from the stored list
|
|
4585
|
+
// (a just-created group) sort after the known ones by locale.
|
|
4586
|
+
// v0.36.5: the SAME list may also carry "w:<workspaceId>" tokens,
|
|
4587
|
+
// which interleave a BARE workspace row with the group rows of its
|
|
4588
|
+
// level. Before this a level always rendered every folder first and
|
|
4589
|
+
// every workspace after, so a top-level workspace could never be
|
|
4590
|
+
// dropped "as the first row" — the probe showed the commit landing
|
|
4591
|
+
// but the row staying at the bottom ("插到第一个永远失败").
|
|
4592
|
+
// Tokens absent from the list (a new member, a workspace that just
|
|
4593
|
+
// moved in) append in the default order: groups first, then
|
|
4594
|
+
// workspaces in registry order.
|
|
4595
|
+
const orderOf = (parentKey) => {
|
|
4596
|
+
if (!folderOrder || typeof folderOrder !== 'object') return null
|
|
4597
|
+
const list = folderOrder[parentKey]
|
|
4598
|
+
return Array.isArray(list) ? list : null
|
|
4599
|
+
}
|
|
4600
|
+
const sortRec = (node, parentKey) => {
|
|
4601
|
+
const order = orderOf(parentKey)
|
|
4602
|
+
const names = order
|
|
4603
|
+
? order.filter((token) => typeof token === 'string' && token !== '' && !token.startsWith('w:'))
|
|
4604
|
+
: null
|
|
4605
|
+
if (names && names.length) {
|
|
4606
|
+
const rank = new Map()
|
|
4607
|
+
for (let i = 0; i < names.length; i += 1) rank.set(names[i], i)
|
|
4608
|
+
node.folders.sort((a, b) => {
|
|
4609
|
+
const ra = rank.has(a.name) ? rank.get(a.name) : names.length
|
|
4610
|
+
const rb = rank.has(b.name) ? rank.get(b.name) : names.length
|
|
4611
|
+
if (ra !== rb) return ra - rb
|
|
4612
|
+
return a.name.localeCompare(b.name, 'zh')
|
|
4613
|
+
})
|
|
4614
|
+
} else {
|
|
4615
|
+
node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
|
4616
|
+
}
|
|
4617
|
+
// Level identity every workspace row carries into its drag payload:
|
|
4618
|
+
// the mixed reorder is siblings-only, so the drop gate compares it
|
|
4619
|
+
// with parentKeyOf(target group idPath).
|
|
4620
|
+
for (const w of node.workspaces) w.levelKey = parentKey
|
|
4621
|
+
// Mixed display sequence of THIS level: the persisted order first
|
|
4622
|
+
// (groups and bare workspaces interleaved), then whatever the list
|
|
4623
|
+
// does not mention, groups before workspaces.
|
|
4624
|
+
const folderByName = new Map(node.folders.map((child) => [child.name, child]))
|
|
4625
|
+
const wsById = new Map(node.workspaces.map((w) => [w.workspaceId, w]))
|
|
4626
|
+
const seq = []
|
|
4627
|
+
const seenFolders = new Set()
|
|
4628
|
+
const seenWorkspaces = new Set()
|
|
4629
|
+
for (const token of order || []) {
|
|
4630
|
+
if (typeof token !== 'string' || token === '') continue
|
|
4631
|
+
if (token.startsWith('w:')) {
|
|
4632
|
+
const id = token.slice(2)
|
|
4633
|
+
const entry = wsById.get(id)
|
|
4634
|
+
if (!entry || seenWorkspaces.has(id)) continue
|
|
4635
|
+
seq.push({ kind: 'ws', entry })
|
|
4636
|
+
seenWorkspaces.add(id)
|
|
4637
|
+
} else {
|
|
4638
|
+
const child = folderByName.get(token)
|
|
4639
|
+
if (!child || seenFolders.has(token)) continue
|
|
4640
|
+
seq.push({ kind: 'folder', node: child })
|
|
4641
|
+
seenFolders.add(token)
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
for (const child of node.folders) if (!seenFolders.has(child.name)) seq.push({ kind: 'folder', node: child })
|
|
4645
|
+
for (const w of node.workspaces) if (!seenWorkspaces.has(w.workspaceId)) seq.push({ kind: 'ws', entry: w })
|
|
4646
|
+
node.seq = seq
|
|
4647
|
+
for (const child of node.folders) sortRec(child, child.idPath)
|
|
4570
4648
|
}
|
|
4571
|
-
sortRec(root)
|
|
4649
|
+
sortRec(root, idPrefix)
|
|
4572
4650
|
return root
|
|
4573
4651
|
}
|
|
4574
4652
|
const topLevel = list.filter((w) => diskParentOf.get(w.workspaceId) === undefined)
|
|
@@ -4673,7 +4751,7 @@ window.__ModuleLoader__.load({
|
|
|
4673
4751
|
'.bw-rows-exits{position:fixed;inset:0;contain:strict;overflow:clip;pointer-events:none}',
|
|
4674
4752
|
'.bw-tree[data-bw-density="compact"]{--bw-row-h:28px;--bw-session-h:26px}',
|
|
4675
4753
|
'.bw-tree[data-bw-density="roomy"]{--bw-row-h:40px;--bw-session-h:38px}',
|
|
4676
|
-
'.bw-row{display:flex;align-items:center;gap:6px;height:var(--bw-row-h,34px);padding:0 8px;padding-inline-start:calc(8px + var(--bw-indent,0px));border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative}',
|
|
4754
|
+
'.bw-row{display:flex;align-items:center;gap:6px;height:var(--bw-row-h,34px);padding:0 8px;padding-inline-start:calc(8px + var(--bw-indent,0px));border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative;transition:height .15s ease-out,padding .15s ease-out}',
|
|
4677
4755
|
'.bw-drop-before::before{content:"";position:absolute;z-index:1;left:0;right:4px;top:-7px;height:12px;pointer-events:none;background:linear-gradient(55deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 0/5px 7px no-repeat,linear-gradient(125deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 5px/5px 7px no-repeat,linear-gradient(var(--dsw-alias-state-business-primary,#5b8def),var(--dsw-alias-state-business-primary,#5b8def)) 4px 5px/calc(100% - 4px) 2px no-repeat}',
|
|
4678
4756
|
'.bw-drop-after::after{content:"";position:absolute;z-index:1;left:0;right:4px;bottom:-7px;height:12px;pointer-events:none;background:linear-gradient(55deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 0/5px 7px no-repeat,linear-gradient(125deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 5px/5px 7px no-repeat,linear-gradient(var(--dsw-alias-state-business-primary,#5b8def),var(--dsw-alias-state-business-primary,#5b8def)) 4px 5px/calc(100% - 4px) 2px no-repeat}',
|
|
4679
4757
|
'.bw-drop-into{outline:1.5px dashed var(--dsw-alias-brand-primary,#5b8def);outline-offset:-1.5px}',
|
|
@@ -4681,6 +4759,15 @@ window.__ModuleLoader__.load({
|
|
|
4681
4759
|
// rim + a tinted wash so "this drop joins a group" reads stronger than
|
|
4682
4760
|
// the reorder dashes above.
|
|
4683
4761
|
'.bw-drop-into-strong{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px;background:color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 16%, transparent)}',
|
|
4762
|
+
// v0.36.1 edge-band reorder slot on group rows: the row SPLITS OPEN to
|
|
4763
|
+
// twice its height so "a row will land here" is visible without
|
|
4764
|
+
// staring for the thin official line. Both the height and the content
|
|
4765
|
+
// slide are transitioned (padding, which CAN animate — align-items
|
|
4766
|
+
// cannot, it teleports the content and feels janky), so the row
|
|
4767
|
+
// opens/closes like a drawer, not a flash. The gap fills with a faint
|
|
4768
|
+
// brand wash; the middle half of the row (drop-into) never opens.
|
|
4769
|
+
'.bw-slot-above{padding-top:var(--bw-row-h,34px);background:linear-gradient(to bottom, color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 10%, transparent) 0, transparent calc(var(--bw-row-h,34px)))}',
|
|
4770
|
+
'.bw-slot-below{padding-bottom:var(--bw-row-h,34px);background:linear-gradient(to top, color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 10%, transparent) 0, transparent calc(var(--bw-row-h,34px)))}',
|
|
4684
4771
|
// Session drag whose source sits in a group: a quiet dashed line at the
|
|
4685
4772
|
// bottom of the tree says "anywhere outside a group = out of it". Kept
|
|
4686
4773
|
// faint on purpose — the row outlines are the loud feedback.
|
|
@@ -4978,7 +5065,7 @@ window.__ModuleLoader__.load({
|
|
|
4978
5065
|
// LITERAL, not a spread of DEFAULT_APPEARANCE: init() is eval-harness
|
|
4979
5066
|
// friendly (no cross-section reference), and the smoke suite asserts the
|
|
4980
5067
|
// two stay in lockstep field by field.
|
|
4981
|
-
init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} } }),
|
|
5068
|
+
init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} }, folderOrder: {} }),
|
|
4982
5069
|
// NOTE: hydration REPLACES the state with the persisted whole value —
|
|
4983
5070
|
// init defaults never merge. Every action must tolerate a missing key
|
|
4984
5071
|
// (states persisted by older plugin versions lack sessionGroups), and
|
|
@@ -5038,6 +5125,19 @@ window.__ModuleLoader__.load({
|
|
|
5038
5125
|
},
|
|
5039
5126
|
// v0.21.0 collapsible Ungrouped block (issue #6).
|
|
5040
5127
|
setUngroupedOpen: (d, value) => { d.ungroupedOpen = value },
|
|
5128
|
+
// Persisted GROUP order (v0.36.4), one name list per PARENT identity
|
|
5129
|
+
// (a level's idPrefix for the root level, else the parent group's
|
|
5130
|
+
// idPath). Only sibling reorders are written here — a cross-parent
|
|
5131
|
+
// move is a rename, not an order change, and stays refused in the
|
|
5132
|
+
// drop handler. An empty list deletes the entry so the name sort
|
|
5133
|
+
// comes back for that parent.
|
|
5134
|
+
setFolderOrder: (d, parentKey, names) => {
|
|
5135
|
+
if (!d.folderOrder || typeof d.folderOrder !== 'object') d.folderOrder = {}
|
|
5136
|
+
const key = String(parentKey == null ? '' : parentKey)
|
|
5137
|
+
const clean = (Array.isArray(names) ? names : []).map((n) => String(n)).filter((n) => n !== '')
|
|
5138
|
+
if (clean.length === 0) delete d.folderOrder[key]
|
|
5139
|
+
else d.folderOrder[key] = clean
|
|
5140
|
+
},
|
|
5041
5141
|
// Explicit (possibly EMPTY) groups, v0.17.0. A group has always been a
|
|
5042
5142
|
// projection of "/" in member titles, so a group with no members simply
|
|
5043
5143
|
// did not exist and "create a group" had nothing to write. These two
|
|
@@ -5977,7 +6077,7 @@ window.__ModuleLoader__.load({
|
|
|
5977
6077
|
}
|
|
5978
6078
|
}
|
|
5979
6079
|
|
|
5980
|
-
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
|
|
6080
|
+
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dropSlot, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
|
|
5981
6081
|
const total = countWorkspaces(node)
|
|
5982
6082
|
const iconEl = iconOf(iconMode, expanded)
|
|
5983
6083
|
// Hidden status dots breathe on the icon; with the icon hidden (mode
|
|
@@ -5986,7 +6086,14 @@ window.__ModuleLoader__.load({
|
|
|
5986
6086
|
? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
|
|
5987
6087
|
: iconEl
|
|
5988
6088
|
return E('div', {
|
|
5989
|
-
|
|
6089
|
+
// v0.36.4: an edge band opens the EMPTY reorder slot immediately —
|
|
6090
|
+
// the row grows by one row height on the dropped side, so the
|
|
6091
|
+
// insertion point is a real gap the block slides into (the user
|
|
6092
|
+
// report: a thin line is not "撑开让位").
|
|
6093
|
+
className: cls('bw-row', 'bw-container-row',
|
|
6094
|
+
dropSlot === 'before' && 'bw-slot-above',
|
|
6095
|
+
dropSlot === 'after' && 'bw-slot-below',
|
|
6096
|
+
dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
|
|
5990
6097
|
'data-row-key': dataRowKey,
|
|
5991
6098
|
style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
|
|
5992
6099
|
onClick: onToggle,
|
|
@@ -6012,7 +6119,11 @@ window.__ModuleLoader__.load({
|
|
|
6012
6119
|
? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
|
|
6013
6120
|
: iconEl
|
|
6014
6121
|
let row = E('div', {
|
|
6015
|
-
|
|
6122
|
+
// v0.36.4: workspace rows open the same empty reorder slot a group
|
|
6123
|
+
// row does (the official 12px insert line alone read as "没撑开").
|
|
6124
|
+
// The half split is judged in LOGICAL height, so the grown row
|
|
6125
|
+
// cannot flip its own band under the pointer.
|
|
6126
|
+
className: cls('bw-row', 'bw-container-row', currentInside && 'bw-row-current', menuOpen && 'menu-open', dropHalf === 'before' && 'bw-slot-above', dropHalf === 'after' && 'bw-slot-below', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
|
|
6016
6127
|
'data-row-key': dataRowKey,
|
|
6017
6128
|
style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
|
|
6018
6129
|
onClick: onToggle,
|
|
@@ -7287,6 +7398,12 @@ window.__ModuleLoader__.load({
|
|
|
7287
7398
|
const wsFolders = typeof useStore === 'function'
|
|
7288
7399
|
? useStore(state => (state && state.folders && state.folders.ws) || EMPTY_FOLDERS)
|
|
7289
7400
|
: EMPTY_FOLDERS
|
|
7401
|
+
// Same persisted group order the sidebar uses (v0.36.4) — a chooser that
|
|
7402
|
+
// listed groups alphabetically while the tree showed the dragged order
|
|
7403
|
+
// would contradict the surface it selects for.
|
|
7404
|
+
const pickerFolderOrder = typeof useStore === 'function'
|
|
7405
|
+
? useStore(state => (state && state.folderOrder) || EMPTY_FOLDERS)
|
|
7406
|
+
: EMPTY_FOLDERS
|
|
7290
7407
|
// Occupancy is the only reactive fact here: the ledger read re-runs on
|
|
7291
7408
|
// every render, so the count just forces the re-render when the shipped
|
|
7292
7409
|
// occupant loads or unloads (its identity is stable while occupied).
|
|
@@ -7308,10 +7425,10 @@ window.__ModuleLoader__.load({
|
|
|
7308
7425
|
// Same construction the sidebar runs (buildTree + optional chain
|
|
7309
7426
|
// compression), so both surfaces answer the same view options.
|
|
7310
7427
|
const tree = React.useMemo(() => {
|
|
7311
|
-
const built = buildTree(items, groupMode, wsFolders)
|
|
7428
|
+
const built = buildTree(items, groupMode, wsFolders, pickerFolderOrder)
|
|
7312
7429
|
if (!compactChains) return built
|
|
7313
7430
|
return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
|
|
7314
|
-
}, [items, groupMode, wsFolders, compactChains])
|
|
7431
|
+
}, [items, groupMode, wsFolders, pickerFolderOrder, compactChains])
|
|
7315
7432
|
|
|
7316
7433
|
const rows = React.useMemo(() => {
|
|
7317
7434
|
const out = []
|
|
@@ -7542,6 +7659,10 @@ window.__ModuleLoader__.load({
|
|
|
7542
7659
|
const foldersMap = useStore ? (useStore(s => s.folders) || {}) : {}
|
|
7543
7660
|
const wsFolders = foldersMap && typeof foldersMap.ws === 'object' && foldersMap.ws !== null ? foldersMap.ws : EMPTY_FOLDERS
|
|
7544
7661
|
const sessFolders = foldersMap && typeof foldersMap.sess === 'object' && foldersMap.sess !== null ? foldersMap.sess : EMPTY_FOLDERS
|
|
7662
|
+
// Persisted group order (v0.36.4): { [parentKey]: name[] }. Absent on
|
|
7663
|
+
// every snapshot written before it existed — the seed name sort then
|
|
7664
|
+
// stands, so old states render unchanged.
|
|
7665
|
+
const folderOrderMap = useStore ? (useStore(s => s.folderOrder) || EMPTY_FOLDERS) : EMPTY_FOLDERS
|
|
7545
7666
|
// View options (v0.13). Selector reads take fallbacks: hydration
|
|
7546
7667
|
// replaces the whole persisted value, so states written by older
|
|
7547
7668
|
// plugin versions lack these keys (hard store discipline).
|
|
@@ -7886,13 +8007,22 @@ window.__ModuleLoader__.load({
|
|
|
7886
8007
|
const [copyNotice, setCopyNotice] = React.useState(null) // { seq, ok } copy-session-id notice
|
|
7887
8008
|
const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
|
|
7888
8009
|
const [errorText, setErrorText] = React.useState(null)
|
|
7889
|
-
const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
|
|
8010
|
+
const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session'|'folder', source, over } | null
|
|
7890
8011
|
// Workspace drags arm their state one frame LATE (see workspaceDragEvents):
|
|
7891
8012
|
// arming synchronously re-renders during the dragstart dispatch, the chain
|
|
7892
8013
|
// expansion inserts rows above the drag source, the cursor leaves the
|
|
7893
8014
|
// source element, and Chromium cancels the whole gesture. dragEnd clears
|
|
7894
8015
|
// the timer so a same-tick cancel never leaves a ghost drag behind.
|
|
7895
8016
|
const wsDragArmTimer = React.useRef(null)
|
|
8017
|
+
// (v0.36.3) The 600ms group-hover auto-expand is retired: it kept
|
|
8018
|
+
// opening groups under a passing cursor ("变下级会被撑开"). The
|
|
8019
|
+
// deterministic reveal now lives in commitFolderMoveInto — the target
|
|
8020
|
+
// group opens when the members actually land.
|
|
8021
|
+
// (v0.36.4) The hover-intent arming is retired. It made the reorder
|
|
8022
|
+
// slot appear only after a ~200ms rest on the band, so a normal drag
|
|
8023
|
+
// across a group row showed the thin insert line and never the empty
|
|
8024
|
+
// slot — the user report "还是一条蓝线,没拨开让我插入". The edge bands
|
|
8025
|
+
// are 30% wide and geometrically judged, so they can answer at once.
|
|
7896
8026
|
// Quote-on-land eligibility sets (mount-scoped):
|
|
7897
8027
|
// - blankSeen: ids observed BLANK in any snapshot. A blank row is a
|
|
7898
8028
|
// freshly created New Session, so ONLY these may ever receive an
|
|
@@ -8049,7 +8179,12 @@ window.__ModuleLoader__.load({
|
|
|
8049
8179
|
const normalizedQuery = query.trim().toLowerCase()
|
|
8050
8180
|
const now = Date.now()
|
|
8051
8181
|
|
|
8052
|
-
|
|
8182
|
+
// v0.36.4: no setFlowOpen here. flowOpen belongs to the PICKER component
|
|
8183
|
+
// (WorkspacePicker), not to this browser: referencing it from here threw
|
|
8184
|
+
// 'ReferenceError: setFlowOpen is not defined' on every failure path —
|
|
8185
|
+
// including the .catch(fail) of a group move-into, which then silently
|
|
8186
|
+
// aborted the rename ('变下级没反应').
|
|
8187
|
+
const fail = (text) => { setDialog(null); setErrorText(String(text || 'unknown error')) }
|
|
8053
8188
|
|
|
8054
8189
|
const styleEntry = (key) => stylingMap[key] || null
|
|
8055
8190
|
// Effective appearance = the settings card's default, overridden field by
|
|
@@ -8078,7 +8213,11 @@ window.__ModuleLoader__.load({
|
|
|
8078
8213
|
return Object.keys(style).length > 0 ? style : null
|
|
8079
8214
|
}
|
|
8080
8215
|
const keyOf = (kind, payload) => {
|
|
8081
|
-
|
|
8216
|
+
// Render keys folders by idPath (renderPlainFolder), which prefixes
|
|
8217
|
+
// the ws id to disambiguate same-named groups across disk levels;
|
|
8218
|
+
// saving by payload.path alone made sub-level group styling
|
|
8219
|
+
// unreachable (name-slash group appearance never applied).
|
|
8220
|
+
if (kind === 'folder') return 'folder:' + (payload.idPath || payload.path)
|
|
8082
8221
|
if (kind === 'workspace') return 'workspace:' + payload.workspaceId
|
|
8083
8222
|
if (kind === 'session') return 'session:' + payload.id
|
|
8084
8223
|
if (kind === 'sgroup') return 'sgroup:' + payload.workspaceId + '|' + payload.path
|
|
@@ -8112,26 +8251,33 @@ window.__ModuleLoader__.load({
|
|
|
8112
8251
|
pinnedPartitionInPlace(ungrouped)
|
|
8113
8252
|
}
|
|
8114
8253
|
|
|
8115
|
-
//
|
|
8116
|
-
// a
|
|
8117
|
-
//
|
|
8118
|
-
//
|
|
8119
|
-
//
|
|
8120
|
-
//
|
|
8121
|
-
// their drop targets live inside workspace rows, which compression merges.
|
|
8122
|
-
const draggingWorkspace = drag !== null && drag.kind === 'workspace'
|
|
8254
|
+
// (v0.36.4) The drag-time chain EXPANSION is retired. Suspending the
|
|
8255
|
+
// merge while a workspace was dragged re-exposed every single-child
|
|
8256
|
+
// chain as folder rows, so the tree visibly re-laid itself one frame
|
|
8257
|
+
// after dragstart and every drop target below the source shifted —
|
|
8258
|
+
// the user report "拖拽中树会乱/落点漂移". Drop targets are now
|
|
8259
|
+
// answered by the stable render; nothing re-flows mid-drag.
|
|
8123
8260
|
const tree = React.useMemo(() => {
|
|
8124
|
-
const built = buildTree(items, workspaceGroupMode, wsFolders)
|
|
8125
|
-
if (!compactChains
|
|
8126
|
-
|
|
8127
|
-
|
|
8261
|
+
const built = buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap)
|
|
8262
|
+
if (!compactChains) return built
|
|
8263
|
+
const folders = built.folders.map((f) => materializeChain(compressTree(f)))
|
|
8264
|
+
// v0.36.5: keep the MIXED root sequence across chain compression —
|
|
8265
|
+
// the compressed node stands in for the raw folder it came from, so
|
|
8266
|
+
// a bare workspace stays interleaved where the user put it.
|
|
8267
|
+
const byName = new Map()
|
|
8268
|
+
for (let i = 0; i < built.folders.length; i += 1) byName.set(built.folders[i].name, folders[i])
|
|
8269
|
+
const seq = Array.isArray(built.seq)
|
|
8270
|
+
? built.seq.map((item) => item.kind === 'folder' ? { kind: 'folder', node: byName.get(item.node.name) || item.node } : item)
|
|
8271
|
+
: built.seq
|
|
8272
|
+
return { ...built, folders, workspaces: built.workspaces, seq }
|
|
8273
|
+
}, [items, compactChains, workspaceGroupMode, wsFolders, folderOrderMap])
|
|
8128
8274
|
// UNCOMPRESSED tree, for the new-group dialog only. compressTree merges a
|
|
8129
8275
|
// single-child chain into one row that stands for BOTH a group and the
|
|
8130
8276
|
// workspace under it — fine for drawing, useless for choosing a
|
|
8131
8277
|
// container: one half must stay unselectable and the other must be
|
|
8132
8278
|
// selectable, and a merged row cannot be both. (It also carried no
|
|
8133
8279
|
// `name`, which is how a lone "E" rendered blank.)
|
|
8134
|
-
const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders), [items, workspaceGroupMode, wsFolders])
|
|
8280
|
+
const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap), [items, workspaceGroupMode, wsFolders, folderOrderMap])
|
|
8135
8281
|
// Disk-layer ancestry for drag semantics (same derivation as buildTree).
|
|
8136
8282
|
const diskParentMap = React.useMemo(() => diskParentMapOf(items), [items])
|
|
8137
8283
|
|
|
@@ -8174,15 +8320,7 @@ window.__ModuleLoader__.load({
|
|
|
8174
8320
|
// presentational: nothing is persisted, the host order is untouched,
|
|
8175
8321
|
// and drag-to-reorder anchors are suppressed while active (an order
|
|
8176
8322
|
// the very next render would re-sort is a lie).
|
|
8177
|
-
if (orderBy === 'updated')
|
|
8178
|
-
const sorted = rows.slice().sort((a, b) => (b.updatedAt - a.updatedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
8179
|
-
const pin = currentId !== undefined && sorted.find((row) => row.id === currentId && row.blank)
|
|
8180
|
-
if (pin) {
|
|
8181
|
-
const at = sorted.indexOf(pin)
|
|
8182
|
-
if (at > 0) { sorted.splice(at, 1); sorted.unshift(pin) }
|
|
8183
|
-
}
|
|
8184
|
-
return pinnedPartition(sorted)
|
|
8185
|
-
}
|
|
8323
|
+
if (orderBy === 'updated') return orderSessionsByRecency(rows, currentId)
|
|
8186
8324
|
const local = typeof insertSessionBefore === 'function' ? [] : sessionOrderOf(workspace.workspaceId)
|
|
8187
8325
|
if (local.length === 0) return pinnedPartition(rows)
|
|
8188
8326
|
const remaining = new Map(rows.map((row) => [row.id, row]))
|
|
@@ -8634,11 +8772,72 @@ window.__ModuleLoader__.load({
|
|
|
8634
8772
|
}
|
|
8635
8773
|
}, [drag === null])
|
|
8636
8774
|
|
|
8775
|
+
// Geometry helpers shared by both reorder targets. v0.36.4 judges in
|
|
8776
|
+
// v0.36.4 geometry: an open reorder slot (bw-slot-above / -below) pads
|
|
8777
|
+
// the row open by one logical row (padding, so the box grows to two
|
|
8778
|
+
// rows). Judge the pointer against the row's ORIGINAL position, not the
|
|
8779
|
+
// padded box: the original position is the half its content did NOT
|
|
8780
|
+
// slide into — the pad above for a before-slot, the remaining content
|
|
8781
|
+
// box for an after-slot. Judging there keeps a still pointer from
|
|
8782
|
+
// flipping the slot the moment it opens (the reported "抽搐"), while
|
|
8783
|
+
// still letting the pointer cross the original midpoint to switch
|
|
8784
|
+
// direction — the padded half answers null instead of a stale band.
|
|
8785
|
+
const originalBoxOf = (el) => {
|
|
8786
|
+
const rect = el.getBoundingClientRect()
|
|
8787
|
+
const style = getComputedStyle(el)
|
|
8788
|
+
const padTop = parseFloat(style.paddingTop) || 0
|
|
8789
|
+
const padBottom = parseFloat(style.paddingBottom) || 0
|
|
8790
|
+
if (padTop > 0) return { top: rect.top, bottom: rect.top + padTop }
|
|
8791
|
+
if (padBottom > 0) return { top: rect.top, bottom: rect.bottom - padBottom }
|
|
8792
|
+
return { top: rect.top, bottom: rect.bottom }
|
|
8793
|
+
}
|
|
8794
|
+
// null = the pointer sits in the half the open slot vacated: the caller
|
|
8795
|
+
// keeps its current drop target instead of re-deciding on geometry that
|
|
8796
|
+
// only exists because of that slot.
|
|
8797
|
+
// v0.36.5 MIXED LEVEL SEQUENCE. buildTree attaches a `seq` to every
|
|
8798
|
+
// level (group rows and BARE workspace rows interleaved by the
|
|
8799
|
+
// persisted folderOrder, whose tokens may be a group name or
|
|
8800
|
+
// "w:<workspaceId>"); chain-compressed nodes carry no seq and keep the
|
|
8801
|
+
// historical folders-then-workspaces order. Both the renderer and the
|
|
8802
|
+
// reorder commit read the SAME sequence, so what the user sees is what
|
|
8803
|
+
// the drop edits.
|
|
8804
|
+
const levelSeqOf = (level) => (level && Array.isArray(level.seq))
|
|
8805
|
+
? level.seq
|
|
8806
|
+
: [
|
|
8807
|
+
...(((level && level.folders) || []).map((node) => ({ kind: 'folder', node }))),
|
|
8808
|
+
...(((level && level.workspaces) || []).map((entry) => ({ kind: 'ws', entry }))),
|
|
8809
|
+
]
|
|
8810
|
+
const levelTokenOf = (item) => item.kind === 'ws' ? 'w:' + item.entry.workspaceId : item.node.name
|
|
8637
8811
|
const rowHalf = (event) => {
|
|
8638
|
-
const
|
|
8639
|
-
|
|
8812
|
+
const box = originalBoxOf(event.currentTarget)
|
|
8813
|
+
const y = event.clientY
|
|
8814
|
+
if (y < box.top || y > box.bottom) return null
|
|
8815
|
+
return y < (box.top + box.bottom) / 2 ? 'before' : 'after'
|
|
8816
|
+
}
|
|
8817
|
+
// Three-band targeting for group rows (react-arborist style): the
|
|
8818
|
+
// outer 30% of the CONTENT means "reorder against this group", the
|
|
8819
|
+
// middle 40% means "drop into" — so move-into (the framed highlight)
|
|
8820
|
+
// is easy to hit and reorder never swallows the pointer. v0.36.3
|
|
8821
|
+
// widened the bands from a quarter: at 25% they were 8.5px on a 34px
|
|
8822
|
+
// row and practically unhittable (every drop landed "into" instead
|
|
8823
|
+
// of reordering beside).
|
|
8824
|
+
const rowZone = (event) => {
|
|
8825
|
+
const box = originalBoxOf(event.currentTarget)
|
|
8826
|
+
const y = event.clientY
|
|
8827
|
+
if (y < box.top || y > box.bottom) return null
|
|
8828
|
+
const h = box.bottom - box.top
|
|
8829
|
+
if (h < 2) return 'into'
|
|
8830
|
+
const rel = (y - box.top) / h
|
|
8831
|
+
if (rel < 0.3) return 'before'
|
|
8832
|
+
if (rel > 0.7) return 'after'
|
|
8833
|
+
return 'into'
|
|
8640
8834
|
}
|
|
8641
8835
|
const dragMatches = (kind) => drag !== null && drag.kind === kind
|
|
8836
|
+
// A refused hover must DROP the highlight it already owns: leaving a
|
|
8837
|
+
// stale {over} in place drew the previous target's INTO frame on a row
|
|
8838
|
+
// that no longer accepts anything (the user reads it as "it will land
|
|
8839
|
+
// here" and the drop then goes elsewhere).
|
|
8840
|
+
const clearOver = (targetKind, target) => setDrag((current) => (current && current.over && current.over.kind === targetKind && current.over.target === target) ? { ...current, over: null } : current)
|
|
8642
8841
|
const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
|
|
8643
8842
|
// Session REORDER needs the host insertSessionBefore action; dragging a
|
|
8644
8843
|
// session onto a sub-group row (a pure rename) stays available without it.
|
|
@@ -8650,12 +8849,26 @@ window.__ModuleLoader__.load({
|
|
|
8650
8849
|
|
|
8651
8850
|
/* --------------------- workspace drag & drop -------------------- */
|
|
8652
8851
|
|
|
8852
|
+
// v0.36.5: a workspace row answers the SAME slot for both drag kinds —
|
|
8853
|
+
// a workspace slides between workspace rows, a whole group block slides
|
|
8854
|
+
// beside one (mixed reorder), so the visual is one shared mechanism.
|
|
8653
8855
|
const wsDropHalf = (workspaceId) => {
|
|
8654
|
-
if (!dragMatches('workspace')) return null
|
|
8856
|
+
if (!dragMatches('workspace') && !dragMatches('folder')) return null
|
|
8655
8857
|
const over = drag.over
|
|
8656
8858
|
return over && over.kind === 'workspace' && over.target === workspaceId ? over.half : null
|
|
8657
8859
|
}
|
|
8658
|
-
|
|
8860
|
+
// Folder rows light up for a workspace drop (regroup one workspace)
|
|
8861
|
+
// AND a folder drop (whole-group move) alike — the INTO band only:
|
|
8862
|
+
// an edge-band reorder slot draws the official insert line instead.
|
|
8863
|
+
const wsDropInto = (path) => (dragMatches('workspace') || dragMatches('folder')) && drag.over && drag.over.kind === 'folder' && drag.over.target === path && (!drag.over.zone || drag.over.zone === 'into')
|
|
8864
|
+
// v0.36.4: an edge band on a group row answers IMMEDIATELY with the
|
|
8865
|
+
// empty reorder slot (bw-slot-above/below) — the drop position is
|
|
8866
|
+
// visible the moment the pointer enters the band, no intent wait.
|
|
8867
|
+
// v0.36.5: BOTH sources open the slot on a group row. A folder source
|
|
8868
|
+
// reorders the group block; a WORKSPACE source interleaves one bare
|
|
8869
|
+
// workspace row beside the group — the gesture "insert as the first
|
|
8870
|
+
// row" that used to be impossible because every group rendered first.
|
|
8871
|
+
const wsFolderSlotOf = (folderIdPath) => ((dragMatches('folder') || dragMatches('workspace')) && drag.over && drag.over.kind === 'folder' && drag.over.target === folderIdPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) ? drag.over.zone : null
|
|
8659
8872
|
const workspaceDragEvents = (workspace) => ({
|
|
8660
8873
|
draggable: !searching && canDragWorkspace,
|
|
8661
8874
|
onDragStart: (event) => {
|
|
@@ -8684,7 +8897,7 @@ window.__ModuleLoader__.load({
|
|
|
8684
8897
|
if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
|
|
8685
8898
|
wsDragArmTimer.current = setTimeout(() => {
|
|
8686
8899
|
wsDragArmTimer.current = null
|
|
8687
|
-
setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId) }, over: null })
|
|
8900
|
+
setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId), levelKey: workspace.levelKey || '' }, over: null })
|
|
8688
8901
|
}, 0)
|
|
8689
8902
|
},
|
|
8690
8903
|
onDragEnd: () => {
|
|
@@ -8708,11 +8921,57 @@ window.__ModuleLoader__.load({
|
|
|
8708
8921
|
: (current ? { ...current, over: { kind: 'ws-root', target: workspace.workspaceId } } : current))
|
|
8709
8922
|
return
|
|
8710
8923
|
}
|
|
8924
|
+
// v0.36.5 MIXED REORDER: a GROUP block can slide beside a bare
|
|
8925
|
+
// workspace row of the same level (the mirror gesture of a
|
|
8926
|
+
// workspace sliding beside a group row). Siblings only — a group
|
|
8927
|
+
// that lives in another container has no position in this level.
|
|
8928
|
+
if (dragMatches('folder')) {
|
|
8929
|
+
const sourceLevel = drag.source.levelKey || ''
|
|
8930
|
+
if (sourceLevel !== (workspace.levelKey || '')) {
|
|
8931
|
+
// v0.36.7 INTO on the OWNING disk row: a group block dropped on
|
|
8932
|
+
// the workspace row that CONTAINS it answers the same framed
|
|
8933
|
+
// INTO a workspace row gets on a group row ("放入这个工作区
|
|
8934
|
+
// 下"). The only position that means without rewriting a path
|
|
8935
|
+
// is the tail of the level it already lives in, so the drop
|
|
8936
|
+
// commits moveFolderToLevelEnd. Every other cross-level target
|
|
8937
|
+
// stays refused: the disk layer is a filesystem fact.
|
|
8938
|
+
if (sourceLevel === (workspace.subPrefix || '')) {
|
|
8939
|
+
event.preventDefault()
|
|
8940
|
+
event.stopPropagation()
|
|
8941
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
8942
|
+
setDrag(current => (current && current.over && current.over.kind === 'ws-level' && current.over.target === workspace.workspaceId)
|
|
8943
|
+
? current
|
|
8944
|
+
: (current ? { ...current, over: { kind: 'ws-level', target: workspace.workspaceId } } : current))
|
|
8945
|
+
} else {
|
|
8946
|
+
clearOver('workspace', workspace.workspaceId)
|
|
8947
|
+
clearOver('ws-level', workspace.workspaceId)
|
|
8948
|
+
}
|
|
8949
|
+
return
|
|
8950
|
+
}
|
|
8951
|
+
event.preventDefault()
|
|
8952
|
+
event.stopPropagation()
|
|
8953
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
8954
|
+
const half = rowHalf(event)
|
|
8955
|
+
if (half === null) return
|
|
8956
|
+
setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
|
|
8957
|
+
? current
|
|
8958
|
+
: (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
|
|
8959
|
+
return
|
|
8960
|
+
}
|
|
8711
8961
|
if (!dragMatches('workspace')) return
|
|
8962
|
+
// v0.36.4: the SOURCE row is never its own drop target. A reorder
|
|
8963
|
+
// slot pads a row open by one logical row, so the row the drag
|
|
8964
|
+
// started on ends up covering the pointer's first stop — without
|
|
8965
|
+
// this gate the source row swallowed the target, the drop landed on
|
|
8966
|
+
// itself, commitWorkspaceDrop no-oped and the gesture read as
|
|
8967
|
+
// "拖了没反应".
|
|
8968
|
+
if (drag.source && drag.source.workspaceId === workspace.workspaceId) return
|
|
8712
8969
|
event.preventDefault()
|
|
8713
8970
|
event.stopPropagation()
|
|
8714
8971
|
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
8715
8972
|
const half = rowHalf(event)
|
|
8973
|
+
// Pointer in the half the open slot vacated: keep this row's slot.
|
|
8974
|
+
if (half === null) return
|
|
8716
8975
|
setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
|
|
8717
8976
|
? current
|
|
8718
8977
|
: (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
|
|
@@ -8726,6 +8985,24 @@ window.__ModuleLoader__.load({
|
|
|
8726
8985
|
commitSessionMoveOut(workspace.workspaceId)
|
|
8727
8986
|
return
|
|
8728
8987
|
}
|
|
8988
|
+
if (dragMatches('folder')) {
|
|
8989
|
+
const sourceLevel = drag.source.levelKey || ''
|
|
8990
|
+
if (sourceLevel !== (workspace.levelKey || '')) {
|
|
8991
|
+
// v0.36.7: the group's OWNING disk row accepts the framed INTO
|
|
8992
|
+
// (see onDragOver) — put the block back at that level's tail.
|
|
8993
|
+
if (sourceLevel === (workspace.subPrefix || '')) {
|
|
8994
|
+
event.preventDefault()
|
|
8995
|
+
event.stopPropagation()
|
|
8996
|
+
commitFolderMoveToLevelEnd(drag.source)
|
|
8997
|
+
}
|
|
8998
|
+
return
|
|
8999
|
+
}
|
|
9000
|
+
event.preventDefault()
|
|
9001
|
+
event.stopPropagation()
|
|
9002
|
+
const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
|
|
9003
|
+
commitLevelReorder({ kind: 'folder', source: drag.source }, { kind: 'workspace', workspace }, half || 'before')
|
|
9004
|
+
return
|
|
9005
|
+
}
|
|
8729
9006
|
if (!dragMatches('workspace')) return
|
|
8730
9007
|
event.preventDefault()
|
|
8731
9008
|
event.stopPropagation()
|
|
@@ -8733,21 +9010,214 @@ window.__ModuleLoader__.load({
|
|
|
8733
9010
|
commitWorkspaceDrop(workspace, half)
|
|
8734
9011
|
},
|
|
8735
9012
|
})
|
|
8736
|
-
|
|
9013
|
+
// A folder row accepts BOTH drag kinds: a workspace drop regroups that
|
|
9014
|
+
// one workspace (commitWorkspaceMoveInto), a folder drop moves the
|
|
9015
|
+
// whole group — every member retitled under the target prefix
|
|
9016
|
+
// (commitFolderMoveInto, the drag twin of submitFolderRename).
|
|
9017
|
+
// Dropping a group onto itself or its own descendant would loop, so
|
|
9018
|
+
// the hover refuses there and the browser shows its "no drop" cursor.
|
|
9019
|
+
const namePathOf = (folderIdPath) => {
|
|
9020
|
+
const cutAt = folderIdPath.lastIndexOf('//')
|
|
9021
|
+
return cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
|
|
9022
|
+
}
|
|
9023
|
+
// idPath = [<wsId>//]*<namePath>: the owner prefix names the disk
|
|
9024
|
+
// level a group renders in. Reordering only makes sense between
|
|
9025
|
+
// groups of the SAME level — across levels a group's position
|
|
9026
|
+
// belongs to its own container.
|
|
9027
|
+
const ownerPrefixOf = (folderIdPath) => {
|
|
9028
|
+
const cutAt = folderIdPath.lastIndexOf('//')
|
|
9029
|
+
return cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
|
|
9030
|
+
}
|
|
9031
|
+
// PARENT container identity of a group row (v0.36.4) — the key its
|
|
9032
|
+
// sibling order is persisted under. The ROOT level of a disk scope
|
|
9033
|
+
// answers the scope prefix ('' at the top level, '<wsId>' one disk
|
|
9034
|
+
// level down); a deeper group answers the idPath of its parent group.
|
|
9035
|
+
const parentKeyOf = (folderIdPath) => {
|
|
9036
|
+
const cutAt = folderIdPath.lastIndexOf('//')
|
|
9037
|
+
const prefix = cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
|
|
9038
|
+
const namePath = cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
|
|
9039
|
+
const segs = namePath.split('/')
|
|
9040
|
+
segs.pop()
|
|
9041
|
+
const parentNamePath = segs.join('/')
|
|
9042
|
+
return parentNamePath === '' ? prefix : prefix + '//' + parentNamePath
|
|
9043
|
+
}
|
|
9044
|
+
// Locate the container a group order list belongs to inside the RAW
|
|
9045
|
+
// (uncompressed) tree: the ROOT level, a disk sub-level (a
|
|
9046
|
+
// workspace's `sub`), or a group node whose `folders` are its
|
|
9047
|
+
// children. Compression never runs on rawTree, so every group the
|
|
9048
|
+
// user can drop beside still has its own idPath here.
|
|
9049
|
+
const folderContainerOf = (key) => {
|
|
9050
|
+
if (key === '') return rawTree
|
|
9051
|
+
let hit = null
|
|
9052
|
+
const walk = (level) => {
|
|
9053
|
+
if (hit || !level) return
|
|
9054
|
+
for (const w of level.workspaces || []) {
|
|
9055
|
+
if (hit) return
|
|
9056
|
+
if (w.subPrefix === key && w.sub) { hit = w.sub; return }
|
|
9057
|
+
if (w.sub) walk(w.sub)
|
|
9058
|
+
}
|
|
9059
|
+
for (const f of level.folders || []) {
|
|
9060
|
+
if (hit) return
|
|
9061
|
+
if (f.idPath === key) { hit = f; return }
|
|
9062
|
+
walk(f)
|
|
9063
|
+
}
|
|
9064
|
+
}
|
|
9065
|
+
walk(rawTree)
|
|
9066
|
+
return hit
|
|
9067
|
+
}
|
|
9068
|
+
// Whole-block member ids of a name group, from this rendering level
|
|
9069
|
+
// down: a reorder slides EVERY nested workspace (prefixes untouched,
|
|
9070
|
+
// so sub-groups travel with the block; only the registry order
|
|
9071
|
+
// shifts).
|
|
9072
|
+
const collectMemberIds = (folderNode) => {
|
|
9073
|
+
const acc = []
|
|
9074
|
+
const walk = (level) => {
|
|
9075
|
+
for (const w of level.workspaces || []) acc.push(w.workspaceId)
|
|
9076
|
+
for (const f of level.folders || []) {
|
|
9077
|
+
if (f.kind === 'ws') acc.push(f.workspace.workspaceId)
|
|
9078
|
+
else walk(f)
|
|
9079
|
+
}
|
|
9080
|
+
}
|
|
9081
|
+
walk(folderNode)
|
|
9082
|
+
return acc
|
|
9083
|
+
}
|
|
9084
|
+
const folderDropEvents = (idPath, path, node) => ({
|
|
9085
|
+
draggable: !searching,
|
|
9086
|
+
onDragStart: (event) => {
|
|
9087
|
+
if (searching) return
|
|
9088
|
+
event.stopPropagation()
|
|
9089
|
+
try {
|
|
9090
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
9091
|
+
event.dataTransfer.setData('text/plain', idPath)
|
|
9092
|
+
} catch { /* drag payload is best-effort */ }
|
|
9093
|
+
// Same one-frame arm as the workspace drag: Chromium cancels a
|
|
9094
|
+
// just-started drag whose source element moves under the cursor.
|
|
9095
|
+
if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
|
|
9096
|
+
wsDragArmTimer.current = setTimeout(() => {
|
|
9097
|
+
wsDragArmTimer.current = null
|
|
9098
|
+
setDrag({ kind: 'folder', source: { idPath, path, memberIds: collectMemberIds(node), levelKey: parentKeyOf(idPath) }, over: null })
|
|
9099
|
+
}, 0)
|
|
9100
|
+
},
|
|
9101
|
+
onDragEnd: () => {
|
|
9102
|
+
if (wsDragArmTimer.current !== null) { clearTimeout(wsDragArmTimer.current); wsDragArmTimer.current = null }
|
|
9103
|
+
setDrag(null)
|
|
9104
|
+
},
|
|
8737
9105
|
onDragOver: (event) => {
|
|
8738
|
-
|
|
9106
|
+
// Three bands on a group row: the outer 30% bands reorder this
|
|
9107
|
+
// group among its SIBLINGS (an empty slot opens at once — the
|
|
9108
|
+
// drop position is visible the moment the band is entered); the
|
|
9109
|
+
// middle 40% moves the source INSIDE the hovered group (framed,
|
|
9110
|
+
// never opened). Workspace sources keep whole-row move-into
|
|
9111
|
+
// semantics.
|
|
9112
|
+
//
|
|
9113
|
+
// SIBLINGS means the SAME PARENT (parentKeyOf): a band reorder
|
|
9114
|
+
// only permutes one level's group name list, it never rewrites a
|
|
9115
|
+
// title prefix. Cross-parent and self/descendant targets are
|
|
9116
|
+
// refused — no preventDefault, so the platform shows its own
|
|
9117
|
+
// 'no drop' cursor, which is the truth.
|
|
9118
|
+
//
|
|
9119
|
+
// v0.36.4: once THIS row already holds a reorder slot, the whole
|
|
9120
|
+
// original row position answers before/after ONLY (a plain
|
|
9121
|
+
// midpoint split). Judging the three bands again there reintroduced
|
|
9122
|
+
// the INTO band on the very row that just padded open: a pointer
|
|
9123
|
+
// drifting a few pixels inside it flipped to INTO, closed the slot
|
|
9124
|
+
// and read as a flicker ("抽搐") — and worse, the drop then landed
|
|
9125
|
+
// as move-into (a no-op) instead of the reorder the user aimed at.
|
|
9126
|
+
// The three bands still decide the FIRST entry, so INTO (move the
|
|
9127
|
+
// source inside this group) stays reachable.
|
|
9128
|
+
const slotOpen = drag.over !== null && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')
|
|
9129
|
+
const zone = slotOpen ? rowHalf(event) : rowZone(event)
|
|
9130
|
+
const isFolder = dragMatches('folder')
|
|
9131
|
+
const isWorkspace = dragMatches('workspace')
|
|
9132
|
+
if (isFolder) {
|
|
9133
|
+
const targetPath = namePathOf(idPath)
|
|
9134
|
+
// Self / descendant: looping.
|
|
9135
|
+
if (drag.source.path === targetPath || targetPath.startsWith(drag.source.path + '/')) { clearOver('folder', idPath); return }
|
|
9136
|
+
// Ancestor: the group already lives inside. The DIRECT parent is
|
|
9137
|
+
// the container it lives in, so v0.36.7 reopens its MIDDLE band
|
|
9138
|
+
// (the framed INTO): "放进那个组" can only mean "move to the end
|
|
9139
|
+
// of that group", the same feedback a workspace row gets when
|
|
9140
|
+
// dropped INTO its own group. Edge bands still mean nothing
|
|
9141
|
+
// there (leaving a parent is a title rename, not an order
|
|
9142
|
+
// change), and a further ancestor stays refused.
|
|
9143
|
+
if (targetPath !== '' && drag.source.path.startsWith(targetPath + '/')) {
|
|
9144
|
+
const cutAt = drag.source.path.lastIndexOf('/')
|
|
9145
|
+
const directParent = cutAt === -1 ? '' : drag.source.path.slice(0, cutAt)
|
|
9146
|
+
if (targetPath !== directParent || zone !== 'into') { clearOver('folder', idPath); return }
|
|
9147
|
+
}
|
|
9148
|
+
// Pointer in the half the open slot vacated: keep this row's slot
|
|
9149
|
+
// (re-deciding there would flicker the slot it just opened).
|
|
9150
|
+
if (zone === null) {
|
|
9151
|
+
if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
|
|
9152
|
+
event.preventDefault()
|
|
9153
|
+
event.stopPropagation()
|
|
9154
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
9155
|
+
}
|
|
9156
|
+
return
|
|
9157
|
+
}
|
|
9158
|
+
// Edge bands permute ONE level's sibling list; a cross-parent
|
|
9159
|
+
// band is refused. Move-into (the middle band) stays open across
|
|
9160
|
+
// parents: it is a batch rename, which does reach another group.
|
|
9161
|
+
if (zone !== 'into' && parentKeyOf(idPath) !== parentKeyOf(drag.source.idPath)) { clearOver('folder', idPath); return }
|
|
9162
|
+
} else if (!isWorkspace) {
|
|
9163
|
+
return
|
|
9164
|
+
} else {
|
|
9165
|
+
// v0.36.6 LIFT: a bare workspace row slides beside a group of ANY
|
|
9166
|
+
// level it can reach. Same level keeps the v0.36.5 mixed reorder.
|
|
9167
|
+
// Another level is a LIFT: the workspace leaves its current group
|
|
9168
|
+
// and joins the target group's level, inserted right there. That
|
|
9169
|
+
// is the "insert as the FIRST row" gesture the user kept
|
|
9170
|
+
// reporting: the hover used to be refused silently, so {over}
|
|
9171
|
+
// kept the previous frame's INTO, and the drop became a
|
|
9172
|
+
// move-into that left the row last inside the group. A lift may
|
|
9173
|
+
// not cross a DISK boundary (a workspace's folder is a
|
|
9174
|
+
// filesystem fact), so the target level must live in the same
|
|
9175
|
+
// disk scope as the source. The middle band keeps its old
|
|
9176
|
+
// meaning: move the workspace INTO this group.
|
|
9177
|
+
if (zone === null) {
|
|
9178
|
+
if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
|
|
9179
|
+
event.preventDefault()
|
|
9180
|
+
event.stopPropagation()
|
|
9181
|
+
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
9182
|
+
}
|
|
9183
|
+
return
|
|
9184
|
+
}
|
|
9185
|
+
if (zone !== 'into' && (drag.source.levelKey || '') !== parentKeyOf(idPath) && (ownerPrefixOf(idPath) || '') !== (drag.source.disk || '')) { clearOver('folder', idPath); return }
|
|
9186
|
+
}
|
|
8739
9187
|
event.preventDefault()
|
|
8740
9188
|
event.stopPropagation()
|
|
8741
9189
|
try { event.dataTransfer.dropEffect = 'move' } catch { }
|
|
8742
|
-
|
|
9190
|
+
const reorder = zone !== 'into'
|
|
9191
|
+
const nextOver = reorder
|
|
9192
|
+
? { kind: 'folder', target: idPath, zone }
|
|
9193
|
+
: { kind: 'folder', target: idPath, zone: 'into' }
|
|
9194
|
+
setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === idPath && current.over.zone === nextOver.zone)
|
|
8743
9195
|
? current
|
|
8744
|
-
: (current ? { ...current, over:
|
|
9196
|
+
: (current ? { ...current, over: nextOver } : current))
|
|
8745
9197
|
},
|
|
8746
9198
|
onDrop: (event) => {
|
|
9199
|
+
if (dragMatches('folder')) {
|
|
9200
|
+
event.preventDefault()
|
|
9201
|
+
event.stopPropagation()
|
|
9202
|
+
const zone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath ? drag.over.zone : 'into'
|
|
9203
|
+
if (zone === 'before' || zone === 'after') commitFolderReorder(drag.source, idPath, zone, node)
|
|
9204
|
+
else commitFolderMoveInto(drag.source, idPath)
|
|
9205
|
+
return
|
|
9206
|
+
}
|
|
8747
9207
|
if (!dragMatches('workspace')) return
|
|
8748
9208
|
event.preventDefault()
|
|
8749
9209
|
event.stopPropagation()
|
|
8750
|
-
|
|
9210
|
+
// The live {over} wins; the geometry is the fallback for a drop that
|
|
9211
|
+
// lands before any hover frame recorded a band on this row.
|
|
9212
|
+
const overZone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after') ? drag.over.zone : null
|
|
9213
|
+
const geoZone = rowZone(event) || 'into'
|
|
9214
|
+
const zone = overZone || geoZone
|
|
9215
|
+
if (zone === 'before' || zone === 'after') {
|
|
9216
|
+
// Same level = plain mixed reorder; another level (same disk
|
|
9217
|
+
// scope) = lift the workspace out of its group into that level.
|
|
9218
|
+
if ((drag.source.levelKey || '') === parentKeyOf(idPath)) commitLevelReorder({ kind: 'workspace', source: drag.source }, { kind: 'folder', target: idPath }, zone)
|
|
9219
|
+
else commitWorkspaceLift(drag.source, idPath, zone)
|
|
9220
|
+
} else commitWorkspaceMoveInto(idPath)
|
|
8751
9221
|
},
|
|
8752
9222
|
})
|
|
8753
9223
|
const nextWorkspaceAfter = (folderPath, workspaceId) => {
|
|
@@ -8756,6 +9226,87 @@ window.__ModuleLoader__.load({
|
|
|
8756
9226
|
const index = node.workspaces.findIndex(w => w.workspaceId === workspaceId)
|
|
8757
9227
|
return index === -1 ? undefined : (node.workspaces[index + 1] ? node.workspaces[index + 1].workspaceId : undefined)
|
|
8758
9228
|
}
|
|
9229
|
+
// v0.36.5 MIXED LEVEL REORDER. One persisted list per level decides the
|
|
9230
|
+
// display order of BOTH kinds: a group name token and a "w:<id>"
|
|
9231
|
+
// workspace token. Writes the list only — the registry keeps its own
|
|
9232
|
+
// (workspace-only) order, because "above that group" is a VIEW position
|
|
9233
|
+
// the registry has no vocabulary for: a workspace title still decides
|
|
9234
|
+
// which group it belongs to, the list only decides where the row sits.
|
|
9235
|
+
// v0.36.7: a group block dropped INTO the container it already lives in
|
|
9236
|
+
// (the framed INTO on its DIRECT parent group row, or on the disk-level
|
|
9237
|
+
// row that owns it) lands at that container's TAIL — the same
|
|
9238
|
+
// "放入=排到最后" a workspace row gets when dropped INTO its own group.
|
|
9239
|
+
// No prefix is rewritten (the block already lives under the right path),
|
|
9240
|
+
// only the level order moves.
|
|
9241
|
+
const moveFolderToLevelEnd = (source) => {
|
|
9242
|
+
const containerKey = source.levelKey || ''
|
|
9243
|
+
const container = folderContainerOf(containerKey)
|
|
9244
|
+
if (!container) { console.warn('[bw] folder move refused: target level not found', containerKey); return false }
|
|
9245
|
+
const token = namePathOf(source.idPath).split('/').pop()
|
|
9246
|
+
const tokens = levelSeqOf(container).map(levelTokenOf)
|
|
9247
|
+
if (!tokens.includes(token)) { console.warn('[bw] folder move refused: group not in this level', token); return false }
|
|
9248
|
+
const next = tokens.filter((entry) => entry !== token)
|
|
9249
|
+
next.push(token)
|
|
9250
|
+
if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, next)
|
|
9251
|
+
return true
|
|
9252
|
+
}
|
|
9253
|
+
const commitFolderMoveToLevelEnd = (source) => {
|
|
9254
|
+
setDrag(null)
|
|
9255
|
+
moveFolderToLevelEnd(source)
|
|
9256
|
+
}
|
|
9257
|
+
const writeLevelOrder = (containerKey, sourceToken, targetToken, zone) => {
|
|
9258
|
+
const container = folderContainerOf(containerKey)
|
|
9259
|
+
if (!container) { console.warn('[bw] level reorder refused: container not found', containerKey); return false }
|
|
9260
|
+
const tokens = levelSeqOf(container).map(levelTokenOf)
|
|
9261
|
+
const at = tokens.indexOf(sourceToken)
|
|
9262
|
+
if (at === -1) { console.warn('[bw] level reorder refused: source not in this level', sourceToken); return false }
|
|
9263
|
+
tokens.splice(at, 1)
|
|
9264
|
+
const targetAt = tokens.indexOf(targetToken)
|
|
9265
|
+
if (targetAt === -1) { console.warn('[bw] level reorder refused: target not in this level', targetToken); return false }
|
|
9266
|
+
tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
|
|
9267
|
+
if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
|
|
9268
|
+
return true
|
|
9269
|
+
}
|
|
9270
|
+
// v0.36.6 LIFT writes: the source token does NOT exist in the target
|
|
9271
|
+
// level yet (that is what a lift is), so writeLevelOrder's
|
|
9272
|
+
// "source must already be here" precondition does not apply — the token
|
|
9273
|
+
// is spliced straight in beside the target group.
|
|
9274
|
+
const insertLevelToken = (containerKey, sourceToken, targetToken, zone) => {
|
|
9275
|
+
const container = folderContainerOf(containerKey)
|
|
9276
|
+
if (!container) { console.warn('[bw] workspace lift refused: target level not found', containerKey); return false }
|
|
9277
|
+
const tokens = levelSeqOf(container).map(levelTokenOf).filter((token) => token !== sourceToken)
|
|
9278
|
+
const targetAt = tokens.indexOf(targetToken)
|
|
9279
|
+
if (targetAt === -1) { console.warn('[bw] workspace lift refused: target group not in this level', targetToken); return false }
|
|
9280
|
+
tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
|
|
9281
|
+
if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
|
|
9282
|
+
return true
|
|
9283
|
+
}
|
|
9284
|
+
// The level a lifted workspace LEFT keeps a stale "w:<id>" token
|
|
9285
|
+
// otherwise (buildTree skips it harmlessly, but the list would grow
|
|
9286
|
+
// forever and a later re-group could resurrect the old position).
|
|
9287
|
+
const removeLevelToken = (containerKey, token) => {
|
|
9288
|
+
const container = folderContainerOf(containerKey)
|
|
9289
|
+
if (!container) return
|
|
9290
|
+
const tokens = levelSeqOf(container).map(levelTokenOf)
|
|
9291
|
+
if (!tokens.includes(token)) return
|
|
9292
|
+
if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens.filter((entry) => entry !== token))
|
|
9293
|
+
}
|
|
9294
|
+
// Shared commit for both mixed directions — a workspace sliding beside a
|
|
9295
|
+
// group row, and a group block sliding beside a workspace row. The
|
|
9296
|
+
// target names the level it belongs to; the hover gate already refused
|
|
9297
|
+
// cross-level targets and this re-checks it as belt and braces.
|
|
9298
|
+
const commitLevelReorder = (sourceRef, targetRef, zone) => {
|
|
9299
|
+
setDrag(null)
|
|
9300
|
+
if (!canDragWorkspace) { console.warn('[bw] level reorder refused: workspace reorder unavailable on this host'); return }
|
|
9301
|
+
const wsSource = sourceRef.kind === 'workspace'
|
|
9302
|
+
const sourceToken = wsSource ? 'w:' + sourceRef.source.workspaceId : namePathOf(sourceRef.source.idPath).split('/').pop()
|
|
9303
|
+
const folderTarget = targetRef.kind === 'folder'
|
|
9304
|
+
const targetToken = folderTarget ? namePathOf(targetRef.target).split('/').pop() : 'w:' + targetRef.workspace.workspaceId
|
|
9305
|
+
const containerKey = folderTarget ? parentKeyOf(targetRef.target) : (targetRef.workspace.levelKey || '')
|
|
9306
|
+
const sourceLevelKey = sourceRef.source.levelKey || ''
|
|
9307
|
+
if (sourceLevelKey !== containerKey) { console.warn('[bw] level reorder refused: cross-level target', sourceLevelKey, containerKey); return }
|
|
9308
|
+
writeLevelOrder(containerKey, sourceToken, targetToken, zone)
|
|
9309
|
+
}
|
|
8759
9310
|
const commitWorkspaceDrop = (targetWorkspace, half) => {
|
|
8760
9311
|
const source = drag.source
|
|
8761
9312
|
setDrag(null)
|
|
@@ -8769,6 +9320,16 @@ window.__ModuleLoader__.load({
|
|
|
8769
9320
|
// cross-level drop only re-groups the title (the registry append
|
|
8770
9321
|
// lands it at the end of ITS disk level).
|
|
8771
9322
|
const sameDisk = source.disk === diskParentMap.get(targetWorkspace.workspaceId)
|
|
9323
|
+
// v0.36.6: a cross-LEVEL drop on a workspace row's half is a PLACEMENT,
|
|
9324
|
+
// not a re-group. The user reported "the insert line shows but the row
|
|
9325
|
+
// lands somewhere else": renaming into the target's group moved the row
|
|
9326
|
+
// into that group's block at whatever position the group already had.
|
|
9327
|
+
// Lifting to the target row's own level and anchoring at that row is
|
|
9328
|
+
// what makes the drop land where the line was drawn.
|
|
9329
|
+
if (workspaceSlash && (targetWorkspace.levelKey || '') !== (source.levelKey || '')) {
|
|
9330
|
+
commitWorkspaceLift(source, targetWorkspace.levelKey || '', half === 'after' ? 'after' : 'before', targetWorkspace.workspaceId)
|
|
9331
|
+
return
|
|
9332
|
+
}
|
|
8772
9333
|
const chain = sameFolder
|
|
8773
9334
|
? Promise.resolve()
|
|
8774
9335
|
: Promise.resolve().then(() => {
|
|
@@ -8782,6 +9343,69 @@ window.__ModuleLoader__.load({
|
|
|
8782
9343
|
: targetWorkspace.workspaceId)
|
|
8783
9344
|
chain
|
|
8784
9345
|
.then(() => (anchor !== undefined ? insertWorkspaceBefore(source.workspaceId, anchor) : insertWorkspaceBefore(source.workspaceId)))
|
|
9346
|
+
// v0.36.5: once a level carries a MIXED list (it interleaves a bare
|
|
9347
|
+
// workspace with its groups), the registry edit above no longer
|
|
9348
|
+
// moves the row — rewrite the list too, keeping every other token.
|
|
9349
|
+
.then(() => {
|
|
9350
|
+
const levelKey = targetWorkspace.levelKey || ''
|
|
9351
|
+
const container = folderContainerOf(levelKey)
|
|
9352
|
+
const tokens = container ? levelSeqOf(container).map(levelTokenOf) : []
|
|
9353
|
+
if (tokens.some((token) => token.startsWith('w:'))) {
|
|
9354
|
+
writeLevelOrder(levelKey, 'w:' + source.workspaceId, 'w:' + targetWorkspace.workspaceId, half === 'after' ? 'after' : 'before')
|
|
9355
|
+
}
|
|
9356
|
+
})
|
|
9357
|
+
.catch(fail)
|
|
9358
|
+
}
|
|
9359
|
+
// v0.36.6 LIFT. Dropping a bare workspace row on a group's EDGE band
|
|
9360
|
+
// from a DIFFERENT level means: take the row out of its group, give it
|
|
9361
|
+
// the target level's name prefix and place it right beside that group.
|
|
9362
|
+
// The user's report was "I can never insert it as the first row" — the
|
|
9363
|
+
// gesture existed visually but every path landed it inside the group
|
|
9364
|
+
// (move-into) at the tail. The lift is a rename + a level token, never a
|
|
9365
|
+
// disk move, so the caller has already checked the disk scope.
|
|
9366
|
+
const commitWorkspaceLift = (source, targetIdPath, zone, anchorWorkspaceId) => {
|
|
9367
|
+
setDrag(null)
|
|
9368
|
+
if (!canDragWorkspace) { console.warn('[bw] workspace lift refused: workspace reorder unavailable on this host'); return }
|
|
9369
|
+
const targetParentKey = parentKeyOf(targetIdPath)
|
|
9370
|
+
if (!folderContainerOf(targetParentKey)) { console.warn('[bw] workspace lift refused: target level not found', targetParentKey); return }
|
|
9371
|
+
const cutAt = targetParentKey.lastIndexOf('//')
|
|
9372
|
+
const levelNamePath = cutAt === -1 ? targetParentKey : targetParentKey.slice(cutAt + 2)
|
|
9373
|
+
const sourceKey = source.levelKey || ''
|
|
9374
|
+
const targetNamePath = namePathOf(targetIdPath)
|
|
9375
|
+
const targetNode = findTreeNode(rawTree, targetNamePath)
|
|
9376
|
+
const targetMembers = targetNode ? collectMemberIds(targetNode) : []
|
|
9377
|
+
const registryIds = (items || []).map((w) => w.workspaceId)
|
|
9378
|
+
// after = insert before whatever follows the target block in the
|
|
9379
|
+
// registry (undefined = append). before = before the block's head.
|
|
9380
|
+
// A row-anchored lift (dropped on a workspace row's half) anchors at
|
|
9381
|
+
// that row itself, not at the head/tail of its whole group block.
|
|
9382
|
+
let anchor
|
|
9383
|
+
if (anchorWorkspaceId) {
|
|
9384
|
+
if (zone === 'before') {
|
|
9385
|
+
anchor = anchorWorkspaceId
|
|
9386
|
+
} else {
|
|
9387
|
+
const rowAt = registryIds.indexOf(anchorWorkspaceId)
|
|
9388
|
+
anchor = rowAt === -1 ? undefined : registryIds[rowAt + 1]
|
|
9389
|
+
}
|
|
9390
|
+
} else if (zone === 'before') {
|
|
9391
|
+
anchor = targetMembers[0]
|
|
9392
|
+
} else if (targetMembers.length) {
|
|
9393
|
+
const tailAt = registryIds.indexOf(targetMembers[targetMembers.length - 1])
|
|
9394
|
+
anchor = tailAt === -1 ? undefined : registryIds[tailAt + 1]
|
|
9395
|
+
}
|
|
9396
|
+
const newTitle = levelNamePath !== '' ? levelNamePath + '/' + source.leaf : source.leaf
|
|
9397
|
+
const sameNameLevel = (source.folderPath || '') === levelNamePath
|
|
9398
|
+
Promise.resolve()
|
|
9399
|
+
.then(() => (sameNameLevel ? Promise.resolve() : renameWorkspace(source.workspaceId, newTitle)))
|
|
9400
|
+
.then(() => insertWorkspaceBefore(source.workspaceId, anchor))
|
|
9401
|
+
.then(() => {
|
|
9402
|
+
// The level token is always a GROUP token: a workspace row dropped on
|
|
9403
|
+
// from another level is painted beside the group that row lives in,
|
|
9404
|
+
// so the token to place it next to is that group's name in the
|
|
9405
|
+
// parent level's list (the row's own "w:" token lives one level in).
|
|
9406
|
+
insertLevelToken(targetParentKey, 'w:' + source.workspaceId, targetNamePath.split('/').pop(), zone)
|
|
9407
|
+
if (sourceKey !== targetParentKey) removeLevelToken(sourceKey, 'w:' + source.workspaceId)
|
|
9408
|
+
})
|
|
8785
9409
|
.catch(fail)
|
|
8786
9410
|
}
|
|
8787
9411
|
const commitWorkspaceMoveInto = (folderIdPath) => {
|
|
@@ -8798,6 +9422,102 @@ window.__ModuleLoader__.load({
|
|
|
8798
9422
|
.then(() => insertWorkspaceBefore(source.workspaceId))
|
|
8799
9423
|
.catch(fail)
|
|
8800
9424
|
}
|
|
9425
|
+
// Group drag (v0.36.0): moving a folder rewrites EVERY member's title
|
|
9426
|
+
// under the target prefix — the same batch rename the rename-group
|
|
9427
|
+
// dialog (submitFolderRename) performs. Titles are global name paths,
|
|
9428
|
+
// so the member sweep is prefix-based over all items (same known
|
|
9429
|
+
// limitation as that dialog: same-named groups on different disk
|
|
9430
|
+
// levels move together). Self / descendant targets are already
|
|
9431
|
+
// refused at hover time; re-checked here as belt and braces.
|
|
9432
|
+
const commitFolderMoveInto = (source, targetIdPath) => {
|
|
9433
|
+
setDrag(null)
|
|
9434
|
+
const targetPath = namePathOf(targetIdPath)
|
|
9435
|
+
// v0.36.7: the DIRECT parent is the container the group already lives
|
|
9436
|
+
// in. The hover already showed the framed INTO there, so "放进那个
|
|
9437
|
+
// 组" means "move to the end of that group" — no rename involved.
|
|
9438
|
+
// Checked BEFORE the ancestor refusal below.
|
|
9439
|
+
const cutAt = source.path.lastIndexOf('/')
|
|
9440
|
+
const directParent = cutAt === -1 ? '' : source.path.slice(0, cutAt)
|
|
9441
|
+
if (targetPath === directParent) { moveFolderToLevelEnd(source); return }
|
|
9442
|
+
// An ANCESTOR target means the group already lives there: a prefix
|
|
9443
|
+
// rewrite would duplicate a segment (工作/A → 工作/工作/A). Refuse.
|
|
9444
|
+
if (source.path === targetPath || targetPath.startsWith(source.path + '/') || (targetPath !== '' && source.path.startsWith(targetPath + '/'))) { console.warn('[bw] folder move refused: target is self, descendant or ancestor of', source.path); return }
|
|
9445
|
+
const prefix = source.path + '/'
|
|
9446
|
+
const affected = (items || []).filter((w) => String(w.title || '').startsWith(prefix))
|
|
9447
|
+
if (!affected.length) { console.warn('[bw] folder move refused: no members under', source.path); return }
|
|
9448
|
+
Promise.resolve()
|
|
9449
|
+
.then(async () => {
|
|
9450
|
+
for (const w of affected) {
|
|
9451
|
+
await renameWorkspace(w.workspaceId, targetPath + String(w.title).slice(source.path.length))
|
|
9452
|
+
}
|
|
9453
|
+
})
|
|
9454
|
+
// (v0.36.4) NO automatic expand after a move-into. v0.36.3 opened
|
|
9455
|
+
// the target group here so the move would be visible, but on the
|
|
9456
|
+
// real tree a drop in the middle band is exactly the "变下级"
|
|
9457
|
+
// gesture, and auto-opening made that read as "又被撑开了".
|
|
9458
|
+
// The row the user just dropped into stays as it was; the moved
|
|
9459
|
+
// members appear under it once the user opens that group.
|
|
9460
|
+
.catch(fail)
|
|
9461
|
+
}
|
|
9462
|
+
// Group REORDER (v0.36.0): a name group's position IS the registry
|
|
9463
|
+
// order of its members, so reordering = sliding the whole member
|
|
9464
|
+
// block (prefixes untouched) to just before / after the target
|
|
9465
|
+
// group's block. Anchored like commitWorkspaceDrop: before = the
|
|
9466
|
+
// target's first member, after = whatever follows the target's last
|
|
9467
|
+
// member (undefined = block-append at the level tail). Hover only
|
|
9468
|
+
// arms this between same-level groups (identical owner prefix);
|
|
9469
|
+
// re-checked here as belt and braces.
|
|
9470
|
+
const commitFolderReorder = (source, targetIdPath, zone, targetNode) => {
|
|
9471
|
+
setDrag(null)
|
|
9472
|
+
if (!canDragWorkspace) { console.warn('[bw] folder reorder refused: workspace reorder unavailable on this host'); return }
|
|
9473
|
+
if (ownerPrefixOf(source.idPath) !== ownerPrefixOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-level target'); return }
|
|
9474
|
+
// v0.36.4: SIBLINGS ONLY. A group's rendered position is decided by
|
|
9475
|
+
// its parent's order list, so a target that hangs off a different
|
|
9476
|
+
// parent cannot place it at all.
|
|
9477
|
+
if (parentKeyOf(source.idPath) !== parentKeyOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-parent target'); return }
|
|
9478
|
+
const sourceIds = source.memberIds || []
|
|
9479
|
+
if (!sourceIds.length) { console.warn('[bw] folder reorder refused: empty source block'); return }
|
|
9480
|
+
// Whole-block anchors: the target's members include its nested
|
|
9481
|
+
// sub-groups (a plain .workspaces read misses container groups and
|
|
9482
|
+
// silently returned on them — the "drop did nothing" report).
|
|
9483
|
+
// v0.36.3 drag-out: the source block IS part of the target's block
|
|
9484
|
+
// (dropping a nested group onto its parent), so the source subtree
|
|
9485
|
+
// is filtered out before anchoring — otherwise the anchor lands
|
|
9486
|
+
// inside the moving subtree and the drop no-ops or oscillates.
|
|
9487
|
+
const sourceSet = new Set(sourceIds)
|
|
9488
|
+
const targetMembers = collectMemberIds(targetNode).filter((id) => !sourceSet.has(id))
|
|
9489
|
+
if (!targetMembers.length) { console.warn('[bw] folder reorder refused: target has no members to anchor against'); return }
|
|
9490
|
+
// before = the surviving block's first member; after = its last
|
|
9491
|
+
// member (inserting before it = right after the block). Anchoring
|
|
9492
|
+
// on the block tail itself replaces nextWorkspaceAfter, whose
|
|
9493
|
+
// node.workspaces lookup missed nested tails (findIndex -1 →
|
|
9494
|
+
// silent append).
|
|
9495
|
+
const anchor = zone === 'after'
|
|
9496
|
+
? targetMembers[targetMembers.length - 1]
|
|
9497
|
+
: targetMembers[0]
|
|
9498
|
+
// v0.36.4: THE DISPLAYED ORDER IS PERSISTED HERE. Group order used to
|
|
9499
|
+
// follow buildTree's name sort, so the registry edit below changed
|
|
9500
|
+
// nothing the user could see ("拖了没反应"). folderOrder[parentKey]
|
|
9501
|
+
// is the sibling name list buildTree sorts by; write it first so the
|
|
9502
|
+
// drop is visible immediately, then sync the registry for the
|
|
9503
|
+
// workspace order other surfaces read.
|
|
9504
|
+
const orderParent = parentKeyOf(source.idPath)
|
|
9505
|
+
const sourceName = namePathOf(source.idPath).split('/').pop()
|
|
9506
|
+
const targetName = namePathOf(targetIdPath).split('/').pop()
|
|
9507
|
+
// v0.36.5: write the MIXED token list (group names + w:<id> tokens),
|
|
9508
|
+
// not just the folder names — a level that already interleaves a bare
|
|
9509
|
+
// workspace with its groups must keep those workspace positions.
|
|
9510
|
+
writeLevelOrder(orderParent, sourceName, targetName, zone)
|
|
9511
|
+
Promise.resolve()
|
|
9512
|
+
.then(async () => {
|
|
9513
|
+
if (anchor === undefined) {
|
|
9514
|
+
for (const id of sourceIds) await insertWorkspaceBefore(id)
|
|
9515
|
+
} else {
|
|
9516
|
+
for (const id of sourceIds) await insertWorkspaceBefore(id, anchor)
|
|
9517
|
+
}
|
|
9518
|
+
})
|
|
9519
|
+
.catch(fail)
|
|
9520
|
+
}
|
|
8801
9521
|
|
|
8802
9522
|
/* ---------------------- session drag & drop --------------------- */
|
|
8803
9523
|
|
|
@@ -8925,6 +9645,10 @@ window.__ModuleLoader__.load({
|
|
|
8925
9645
|
.catch(fail)
|
|
8926
9646
|
}
|
|
8927
9647
|
const wsRootDropActive = (workspaceId) => dragMatches('session') && drag.over !== null && drag.over.kind === 'ws-root' && drag.over.target === workspaceId
|
|
9648
|
+
// v0.36.7: a group block hovering the disk-level row that OWNS it lights
|
|
9649
|
+
// the SAME framed INTO a workspace row gets on a group row — the visual
|
|
9650
|
+
// is one language: edge bands reorder, the middle band "puts inside".
|
|
9651
|
+
const wsGroupIntoActive = (workspaceId) => dragMatches('folder') && drag.over !== null && drag.over.kind === 'ws-level' && drag.over.target === workspaceId
|
|
8928
9652
|
const commitSessionMoveInto = (workspaceId, groupPath) => {
|
|
8929
9653
|
const source = drag.source
|
|
8930
9654
|
setDrag(null)
|
|
@@ -9191,7 +9915,7 @@ window.__ModuleLoader__.load({
|
|
|
9191
9915
|
menuOpen: !!(ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId),
|
|
9192
9916
|
hoverBody: workspaceHoverBody,
|
|
9193
9917
|
dropHalf: wsDropHalf(workspace.workspaceId),
|
|
9194
|
-
dropInto: wsRootDropActive(workspace.workspaceId),
|
|
9918
|
+
dropInto: wsRootDropActive(workspace.workspaceId) || wsGroupIntoActive(workspace.workspaceId),
|
|
9195
9919
|
dragEvents: workspaceDragEvents(workspace),
|
|
9196
9920
|
custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
|
|
9197
9921
|
iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
|
|
@@ -9235,8 +9959,11 @@ window.__ModuleLoader__.load({
|
|
|
9235
9959
|
: undefined
|
|
9236
9960
|
const renderLevelRows = (level, depth) => {
|
|
9237
9961
|
const rows = []
|
|
9238
|
-
for (const
|
|
9239
|
-
|
|
9962
|
+
for (const item of levelSeqOf(level)) {
|
|
9963
|
+
rows.push(...(item.kind === 'ws'
|
|
9964
|
+
? renderWorkspaceEntry({ workspace: item.entry }, depth, wsPulseOf(item.entry))
|
|
9965
|
+
: renderPlainFolder(item.node, depth)))
|
|
9966
|
+
}
|
|
9240
9967
|
return rows
|
|
9241
9968
|
}
|
|
9242
9969
|
|
|
@@ -9257,7 +9984,8 @@ window.__ModuleLoader__.load({
|
|
|
9257
9984
|
onToggle: () => { if (!searching) actions.setExpanded(idPath, !expanded) },
|
|
9258
9985
|
onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name, idPath, scope: node.scope, empty: countWorkspaces(node) === 0 }, e),
|
|
9259
9986
|
dropInto: wsDropInto(idPath),
|
|
9260
|
-
|
|
9987
|
+
dropSlot: wsFolderSlotOf(idPath),
|
|
9988
|
+
dragEvents: folderDropEvents(idPath, node.path, node),
|
|
9261
9989
|
custStyle: rowStyleOf('folder:' + idPath),
|
|
9262
9990
|
iconMode: (styleEntry('folder:' + idPath) || {}).icon || 'solid',
|
|
9263
9991
|
pulse: expanded ? null : folderPulseOf(node),
|
|
@@ -9265,8 +9993,11 @@ window.__ModuleLoader__.load({
|
|
|
9265
9993
|
t,
|
|
9266
9994
|
})]
|
|
9267
9995
|
if (expanded) {
|
|
9268
|
-
for (const
|
|
9269
|
-
|
|
9996
|
+
for (const item of levelSeqOf(node)) {
|
|
9997
|
+
rows.push(...(item.kind === 'ws'
|
|
9998
|
+
? renderWorkspaceEntry({ workspace: item.entry }, depth + 1, wsPulseOf(item.entry))
|
|
9999
|
+
: renderPlainFolder(item.node, depth + 1)))
|
|
10000
|
+
}
|
|
9270
10001
|
}
|
|
9271
10002
|
return rows
|
|
9272
10003
|
}
|
|
@@ -9280,7 +10011,10 @@ window.__ModuleLoader__.load({
|
|
|
9280
10011
|
const rows = []
|
|
9281
10012
|
for (const workspace of items || []) rows.push(...sessionsOf(workspace))
|
|
9282
10013
|
rows.push(...ungrouped)
|
|
9283
|
-
|
|
10014
|
+
// sessionsOf() sorts inside one workspace; flat mode needs one global
|
|
10015
|
+
// recency sort, including the Ungrouped bucket (not per-workspace runs).
|
|
10016
|
+
const ordered = orderBy === 'updated' ? orderSessionsByRecency(rows, currentId) : rows
|
|
10017
|
+
return ordered.map((s) => renderSessionRow({ ...s, leaf: s.title }, 0, undefined))
|
|
9284
10018
|
}
|
|
9285
10019
|
const renderUngroupedBlock = () => {
|
|
9286
10020
|
const rows = []
|
|
@@ -9321,8 +10055,11 @@ window.__ModuleLoader__.load({
|
|
|
9321
10055
|
}
|
|
9322
10056
|
bodyRows.push(...renderUngroupedBlock())
|
|
9323
10057
|
} else {
|
|
9324
|
-
for (const
|
|
9325
|
-
|
|
10058
|
+
for (const item of levelSeqOf(tree)) {
|
|
10059
|
+
bodyRows.push(...(item.kind === 'ws'
|
|
10060
|
+
? renderWorkspaceEntry({ workspace: item.entry }, 0, wsPulseOf(item.entry))
|
|
10061
|
+
: renderPlainFolder(item.node, 0)))
|
|
10062
|
+
}
|
|
9326
10063
|
bodyRows.push(...renderUngroupedBlock())
|
|
9327
10064
|
}
|
|
9328
10065
|
const isEmpty = bodyRows.length === 0
|
|
@@ -10195,33 +10932,54 @@ window.__ModuleLoader__.load({
|
|
|
10195
10932
|
if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session search failed')
|
|
10196
10933
|
return result.value
|
|
10197
10934
|
}
|
|
10198
|
-
//
|
|
10199
|
-
//
|
|
10200
|
-
//
|
|
10201
|
-
//
|
|
10202
|
-
// navigation service: ISessions no longer carries open() at all
|
|
10203
|
-
// (navigation belongs to the view owner, replaceMain).
|
|
10935
|
+
// Rename performance (issue #14): sessions.using retains the session and
|
|
10936
|
+
// awaits reference.ready FIRST, so renaming a closed 5MB row stalled ~6s
|
|
10937
|
+
// loading its whole history just to send a 20ms rename RPC (the rename
|
|
10938
|
+
// itself is independent of the transcript). Tiered, cheapest first:
|
|
10204
10939
|
//
|
|
10205
|
-
//
|
|
10206
|
-
//
|
|
10207
|
-
//
|
|
10208
|
-
//
|
|
10209
|
-
//
|
|
10940
|
+
// 1. Open session → borrow binding directly. On 0.1.6+ hosts binding(id)
|
|
10941
|
+
// only sees retained scopes (service.ts:539 `scopes.get(id)?.binding`,
|
|
10942
|
+
// undefined when closed), so "a binding comes back" IS the open test;
|
|
10943
|
+
// on 0.1.5-rc.x hosts every row is visible, identical to the old
|
|
10944
|
+
// fallback. Same SessionBinding object the using path borrows, so
|
|
10945
|
+
// title projection / list refresh behave exactly the same.
|
|
10946
|
+
// 2. Closed session → sessions.manager.rename(id, title): "Rename a
|
|
10947
|
+
// Session ... without opening its history" (manager.ts:578) — a bare
|
|
10948
|
+
// remote.session.rename plus the title projection. manager is a
|
|
10949
|
+
// TS-private field of ClientSessions (service.ts:245), reachable at
|
|
10950
|
+
// runtime; feature-probed so hosts without it skip the tier.
|
|
10951
|
+
// 3. Fallback → the 0.1.6-alpha.2 retention contract: sessions.using
|
|
10952
|
+
// with an explicit source (slow — loads history — but the only legal
|
|
10953
|
+
// path on hosts predating manager.rename). 0.1.5-rc.x hosts ship
|
|
10954
|
+
// ISessions.binding only; tier 1 already served them. Then fail loud.
|
|
10955
|
+
// Fork and open go through the uiWorkspace navigation service: ISessions
|
|
10956
|
+
// no longer carries open() at all (navigation belongs to the view owner,
|
|
10957
|
+
// replaceMain).
|
|
10210
10958
|
const renameSession = async (sessionId, title) => {
|
|
10959
|
+
const fail = (result) => new Error(result && result.error ? result.error.message : 'session rename failed')
|
|
10960
|
+
const binding = typeof sessions.binding === 'function' ? sessions.binding(sessionId) : undefined
|
|
10961
|
+
const face = binding ? binding.session : undefined
|
|
10962
|
+
if (face && typeof face.rename === 'function') {
|
|
10963
|
+
const result = await face.rename(title)
|
|
10964
|
+
if (!result || !result.ok) throw fail(result)
|
|
10965
|
+
return
|
|
10966
|
+
}
|
|
10967
|
+
const manager = sessions.manager
|
|
10968
|
+
if (manager && typeof manager.rename === 'function') {
|
|
10969
|
+
const result = await manager.rename(sessionId, title)
|
|
10970
|
+
if (!result || !result.ok) throw fail(result)
|
|
10971
|
+
return
|
|
10972
|
+
}
|
|
10211
10973
|
if (typeof sessions.using === 'function') {
|
|
10212
10974
|
const result = await sessions.using(
|
|
10213
10975
|
sessionId,
|
|
10214
10976
|
{ source: 'workspaceOperation' },
|
|
10215
10977
|
(reference) => reference.binding.session.rename(title),
|
|
10216
10978
|
)
|
|
10217
|
-
if (!result || !result.ok) throw
|
|
10979
|
+
if (!result || !result.ok) throw fail(result)
|
|
10218
10980
|
return
|
|
10219
10981
|
}
|
|
10220
|
-
|
|
10221
|
-
const face = binding ? binding.session : undefined
|
|
10222
|
-
if (!face || typeof face.rename !== 'function') throw new Error('session rename is unavailable on this host')
|
|
10223
|
-
const result = await face.rename(title)
|
|
10224
|
-
if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
|
|
10982
|
+
throw new Error('session rename is unavailable on this host')
|
|
10225
10983
|
}
|
|
10226
10984
|
const forkSession = (sessionId) => {
|
|
10227
10985
|
uiWorkspace.forkSession(sessionId)
|