dsh-better-workspace 0.35.1 → 0.36.7

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 CHANGED
@@ -69,7 +69,7 @@
69
69
  ### 其余亮点
70
70
 
71
71
  - **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标;角标 v0.34.0 起默认隐藏、对齐官方,设置卡「显示会话数」可开);会话分组同样可收起;搜索时自动全部展开。
72
- - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组(改写名称前缀);会话行可拖拽——同组内拖到另一会话上/下即调整顺序,拖到**别组的会话行**会把名字前缀改写到那个组(拖到根层的行 = 移出分组),拖到会话子分组行即移动进该分组,拖到**自己的工作区行**或**树里任何组外的地方**(含空白区域)同样移出分组 —— 拖拽期间工作区行整行高亮、树底部有一条淡虚线提示这个出口;带分组前缀的会话行右键还多一项「移出分组」。显示顺序 = 宿主手动序,拖拽结果立即生效。**磁盘层是文件系统事实,不随拖拽改变**:跨磁盘层的放置只重组名称分组(工作区落回自身磁盘层的末尾),同层放置才走锚点序。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
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.35.1",
3
+ "version": "0.36.7",
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.35.1",
3
+ "version": "0.36.7",
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
@@ -4485,7 +4485,7 @@ window.__ModuleLoader__.load({
4485
4485
  return out
4486
4486
  }
4487
4487
 
4488
- function buildTree(items, groupMode, explicitWs) {
4488
+ function buildTree(items, groupMode, explicitWs, folderOrder) {
4489
4489
  const list = items || []
4490
4490
  // Issue #7: 'disk' nests by disk only, 'disk-slash' adds the name
4491
4491
  // layer on top (the historical workspaceSlash-on shape), 'slash' runs
@@ -4564,11 +4564,76 @@ window.__ModuleLoader__.load({
4564
4564
  }
4565
4565
  ensure(segs.slice(0, -1)).workspaces.push(entry)
4566
4566
  }
4567
- const sortRec = (node) => {
4568
- node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
4569
- for (const child of node.folders) sortRec(child)
4567
+ // Group order: the NAME sort is only the seed. v0.36.4 keeps a
4568
+ // persisted per-parent name list (folderOrder) so a dragged group
4569
+ // keeps its place — before this the drop committed the registry
4570
+ // order but the renderer re-sorted by name, which is why dragging a
4571
+ // group row looked like a no-op. Names absent from the stored list
4572
+ // (a just-created group) sort after the known ones by locale.
4573
+ // v0.36.5: the SAME list may also carry "w:<workspaceId>" tokens,
4574
+ // which interleave a BARE workspace row with the group rows of its
4575
+ // level. Before this a level always rendered every folder first and
4576
+ // every workspace after, so a top-level workspace could never be
4577
+ // dropped "as the first row" — the probe showed the commit landing
4578
+ // but the row staying at the bottom ("插到第一个永远失败").
4579
+ // Tokens absent from the list (a new member, a workspace that just
4580
+ // moved in) append in the default order: groups first, then
4581
+ // workspaces in registry order.
4582
+ const orderOf = (parentKey) => {
4583
+ if (!folderOrder || typeof folderOrder !== 'object') return null
4584
+ const list = folderOrder[parentKey]
4585
+ return Array.isArray(list) ? list : null
4570
4586
  }
4571
- sortRec(root)
4587
+ const sortRec = (node, parentKey) => {
4588
+ const order = orderOf(parentKey)
4589
+ const names = order
4590
+ ? order.filter((token) => typeof token === 'string' && token !== '' && !token.startsWith('w:'))
4591
+ : null
4592
+ if (names && names.length) {
4593
+ const rank = new Map()
4594
+ for (let i = 0; i < names.length; i += 1) rank.set(names[i], i)
4595
+ node.folders.sort((a, b) => {
4596
+ const ra = rank.has(a.name) ? rank.get(a.name) : names.length
4597
+ const rb = rank.has(b.name) ? rank.get(b.name) : names.length
4598
+ if (ra !== rb) return ra - rb
4599
+ return a.name.localeCompare(b.name, 'zh')
4600
+ })
4601
+ } else {
4602
+ node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
4603
+ }
4604
+ // Level identity every workspace row carries into its drag payload:
4605
+ // the mixed reorder is siblings-only, so the drop gate compares it
4606
+ // with parentKeyOf(target group idPath).
4607
+ for (const w of node.workspaces) w.levelKey = parentKey
4608
+ // Mixed display sequence of THIS level: the persisted order first
4609
+ // (groups and bare workspaces interleaved), then whatever the list
4610
+ // does not mention, groups before workspaces.
4611
+ const folderByName = new Map(node.folders.map((child) => [child.name, child]))
4612
+ const wsById = new Map(node.workspaces.map((w) => [w.workspaceId, w]))
4613
+ const seq = []
4614
+ const seenFolders = new Set()
4615
+ const seenWorkspaces = new Set()
4616
+ for (const token of order || []) {
4617
+ if (typeof token !== 'string' || token === '') continue
4618
+ if (token.startsWith('w:')) {
4619
+ const id = token.slice(2)
4620
+ const entry = wsById.get(id)
4621
+ if (!entry || seenWorkspaces.has(id)) continue
4622
+ seq.push({ kind: 'ws', entry })
4623
+ seenWorkspaces.add(id)
4624
+ } else {
4625
+ const child = folderByName.get(token)
4626
+ if (!child || seenFolders.has(token)) continue
4627
+ seq.push({ kind: 'folder', node: child })
4628
+ seenFolders.add(token)
4629
+ }
4630
+ }
4631
+ for (const child of node.folders) if (!seenFolders.has(child.name)) seq.push({ kind: 'folder', node: child })
4632
+ for (const w of node.workspaces) if (!seenWorkspaces.has(w.workspaceId)) seq.push({ kind: 'ws', entry: w })
4633
+ node.seq = seq
4634
+ for (const child of node.folders) sortRec(child, child.idPath)
4635
+ }
4636
+ sortRec(root, idPrefix)
4572
4637
  return root
4573
4638
  }
4574
4639
  const topLevel = list.filter((w) => diskParentOf.get(w.workspaceId) === undefined)
@@ -4673,7 +4738,7 @@ window.__ModuleLoader__.load({
4673
4738
  '.bw-rows-exits{position:fixed;inset:0;contain:strict;overflow:clip;pointer-events:none}',
4674
4739
  '.bw-tree[data-bw-density="compact"]{--bw-row-h:28px;--bw-session-h:26px}',
4675
4740
  '.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}',
4741
+ '.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
4742
  '.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
4743
  '.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
4744
  '.bw-drop-into{outline:1.5px dashed var(--dsw-alias-brand-primary,#5b8def);outline-offset:-1.5px}',
@@ -4681,6 +4746,15 @@ window.__ModuleLoader__.load({
4681
4746
  // rim + a tinted wash so "this drop joins a group" reads stronger than
4682
4747
  // the reorder dashes above.
4683
4748
  '.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)}',
4749
+ // v0.36.1 edge-band reorder slot on group rows: the row SPLITS OPEN to
4750
+ // twice its height so "a row will land here" is visible without
4751
+ // staring for the thin official line. Both the height and the content
4752
+ // slide are transitioned (padding, which CAN animate — align-items
4753
+ // cannot, it teleports the content and feels janky), so the row
4754
+ // opens/closes like a drawer, not a flash. The gap fills with a faint
4755
+ // brand wash; the middle half of the row (drop-into) never opens.
4756
+ '.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)))}',
4757
+ '.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
4758
  // Session drag whose source sits in a group: a quiet dashed line at the
4685
4759
  // bottom of the tree says "anywhere outside a group = out of it". Kept
4686
4760
  // faint on purpose — the row outlines are the loud feedback.
@@ -4978,7 +5052,7 @@ window.__ModuleLoader__.load({
4978
5052
  // LITERAL, not a spread of DEFAULT_APPEARANCE: init() is eval-harness
4979
5053
  // friendly (no cross-section reference), and the smoke suite asserts the
4980
5054
  // 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: {} } }),
5055
+ 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
5056
  // NOTE: hydration REPLACES the state with the persisted whole value —
4983
5057
  // init defaults never merge. Every action must tolerate a missing key
4984
5058
  // (states persisted by older plugin versions lack sessionGroups), and
@@ -5038,6 +5112,19 @@ window.__ModuleLoader__.load({
5038
5112
  },
5039
5113
  // v0.21.0 collapsible Ungrouped block (issue #6).
5040
5114
  setUngroupedOpen: (d, value) => { d.ungroupedOpen = value },
5115
+ // Persisted GROUP order (v0.36.4), one name list per PARENT identity
5116
+ // (a level's idPrefix for the root level, else the parent group's
5117
+ // idPath). Only sibling reorders are written here — a cross-parent
5118
+ // move is a rename, not an order change, and stays refused in the
5119
+ // drop handler. An empty list deletes the entry so the name sort
5120
+ // comes back for that parent.
5121
+ setFolderOrder: (d, parentKey, names) => {
5122
+ if (!d.folderOrder || typeof d.folderOrder !== 'object') d.folderOrder = {}
5123
+ const key = String(parentKey == null ? '' : parentKey)
5124
+ const clean = (Array.isArray(names) ? names : []).map((n) => String(n)).filter((n) => n !== '')
5125
+ if (clean.length === 0) delete d.folderOrder[key]
5126
+ else d.folderOrder[key] = clean
5127
+ },
5041
5128
  // Explicit (possibly EMPTY) groups, v0.17.0. A group has always been a
5042
5129
  // projection of "/" in member titles, so a group with no members simply
5043
5130
  // did not exist and "create a group" had nothing to write. These two
@@ -5977,7 +6064,7 @@ window.__ModuleLoader__.load({
5977
6064
  }
5978
6065
  }
5979
6066
 
5980
- function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
6067
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dropSlot, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
5981
6068
  const total = countWorkspaces(node)
5982
6069
  const iconEl = iconOf(iconMode, expanded)
5983
6070
  // Hidden status dots breathe on the icon; with the icon hidden (mode
@@ -5986,7 +6073,14 @@ window.__ModuleLoader__.load({
5986
6073
  ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
5987
6074
  : iconEl
5988
6075
  return E('div', {
5989
- className: cls('bw-row', 'bw-container-row', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6076
+ // v0.36.4: an edge band opens the EMPTY reorder slot immediately —
6077
+ // the row grows by one row height on the dropped side, so the
6078
+ // insertion point is a real gap the block slides into (the user
6079
+ // report: a thin line is not "撑开让位").
6080
+ className: cls('bw-row', 'bw-container-row',
6081
+ dropSlot === 'before' && 'bw-slot-above',
6082
+ dropSlot === 'after' && 'bw-slot-below',
6083
+ dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
5990
6084
  'data-row-key': dataRowKey,
5991
6085
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
5992
6086
  onClick: onToggle,
@@ -6012,7 +6106,11 @@ window.__ModuleLoader__.load({
6012
6106
  ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
6013
6107
  : iconEl
6014
6108
  let row = E('div', {
6015
- className: cls('bw-row', 'bw-container-row', currentInside && 'bw-row-current', menuOpen && 'menu-open', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6109
+ // v0.36.4: workspace rows open the same empty reorder slot a group
6110
+ // row does (the official 12px insert line alone read as "没撑开").
6111
+ // The half split is judged in LOGICAL height, so the grown row
6112
+ // cannot flip its own band under the pointer.
6113
+ 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
6114
  'data-row-key': dataRowKey,
6017
6115
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
6018
6116
  onClick: onToggle,
@@ -7287,6 +7385,12 @@ window.__ModuleLoader__.load({
7287
7385
  const wsFolders = typeof useStore === 'function'
7288
7386
  ? useStore(state => (state && state.folders && state.folders.ws) || EMPTY_FOLDERS)
7289
7387
  : EMPTY_FOLDERS
7388
+ // Same persisted group order the sidebar uses (v0.36.4) — a chooser that
7389
+ // listed groups alphabetically while the tree showed the dragged order
7390
+ // would contradict the surface it selects for.
7391
+ const pickerFolderOrder = typeof useStore === 'function'
7392
+ ? useStore(state => (state && state.folderOrder) || EMPTY_FOLDERS)
7393
+ : EMPTY_FOLDERS
7290
7394
  // Occupancy is the only reactive fact here: the ledger read re-runs on
7291
7395
  // every render, so the count just forces the re-render when the shipped
7292
7396
  // occupant loads or unloads (its identity is stable while occupied).
@@ -7308,10 +7412,10 @@ window.__ModuleLoader__.load({
7308
7412
  // Same construction the sidebar runs (buildTree + optional chain
7309
7413
  // compression), so both surfaces answer the same view options.
7310
7414
  const tree = React.useMemo(() => {
7311
- const built = buildTree(items, groupMode, wsFolders)
7415
+ const built = buildTree(items, groupMode, wsFolders, pickerFolderOrder)
7312
7416
  if (!compactChains) return built
7313
7417
  return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
7314
- }, [items, groupMode, wsFolders, compactChains])
7418
+ }, [items, groupMode, wsFolders, pickerFolderOrder, compactChains])
7315
7419
 
7316
7420
  const rows = React.useMemo(() => {
7317
7421
  const out = []
@@ -7542,6 +7646,10 @@ window.__ModuleLoader__.load({
7542
7646
  const foldersMap = useStore ? (useStore(s => s.folders) || {}) : {}
7543
7647
  const wsFolders = foldersMap && typeof foldersMap.ws === 'object' && foldersMap.ws !== null ? foldersMap.ws : EMPTY_FOLDERS
7544
7648
  const sessFolders = foldersMap && typeof foldersMap.sess === 'object' && foldersMap.sess !== null ? foldersMap.sess : EMPTY_FOLDERS
7649
+ // Persisted group order (v0.36.4): { [parentKey]: name[] }. Absent on
7650
+ // every snapshot written before it existed — the seed name sort then
7651
+ // stands, so old states render unchanged.
7652
+ const folderOrderMap = useStore ? (useStore(s => s.folderOrder) || EMPTY_FOLDERS) : EMPTY_FOLDERS
7545
7653
  // View options (v0.13). Selector reads take fallbacks: hydration
7546
7654
  // replaces the whole persisted value, so states written by older
7547
7655
  // plugin versions lack these keys (hard store discipline).
@@ -7886,13 +7994,22 @@ window.__ModuleLoader__.load({
7886
7994
  const [copyNotice, setCopyNotice] = React.useState(null) // { seq, ok } copy-session-id notice
7887
7995
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
7888
7996
  const [errorText, setErrorText] = React.useState(null)
7889
- const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
7997
+ const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session'|'folder', source, over } | null
7890
7998
  // Workspace drags arm their state one frame LATE (see workspaceDragEvents):
7891
7999
  // arming synchronously re-renders during the dragstart dispatch, the chain
7892
8000
  // expansion inserts rows above the drag source, the cursor leaves the
7893
8001
  // source element, and Chromium cancels the whole gesture. dragEnd clears
7894
8002
  // the timer so a same-tick cancel never leaves a ghost drag behind.
7895
8003
  const wsDragArmTimer = React.useRef(null)
8004
+ // (v0.36.3) The 600ms group-hover auto-expand is retired: it kept
8005
+ // opening groups under a passing cursor ("变下级会被撑开"). The
8006
+ // deterministic reveal now lives in commitFolderMoveInto — the target
8007
+ // group opens when the members actually land.
8008
+ // (v0.36.4) The hover-intent arming is retired. It made the reorder
8009
+ // slot appear only after a ~200ms rest on the band, so a normal drag
8010
+ // across a group row showed the thin insert line and never the empty
8011
+ // slot — the user report "还是一条蓝线,没拨开让我插入". The edge bands
8012
+ // are 30% wide and geometrically judged, so they can answer at once.
7896
8013
  // Quote-on-land eligibility sets (mount-scoped):
7897
8014
  // - blankSeen: ids observed BLANK in any snapshot. A blank row is a
7898
8015
  // freshly created New Session, so ONLY these may ever receive an
@@ -8049,7 +8166,12 @@ window.__ModuleLoader__.load({
8049
8166
  const normalizedQuery = query.trim().toLowerCase()
8050
8167
  const now = Date.now()
8051
8168
 
8052
- const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
8169
+ // v0.36.4: no setFlowOpen here. flowOpen belongs to the PICKER component
8170
+ // (WorkspacePicker), not to this browser: referencing it from here threw
8171
+ // 'ReferenceError: setFlowOpen is not defined' on every failure path —
8172
+ // including the .catch(fail) of a group move-into, which then silently
8173
+ // aborted the rename ('变下级没反应').
8174
+ const fail = (text) => { setDialog(null); setErrorText(String(text || 'unknown error')) }
8053
8175
 
8054
8176
  const styleEntry = (key) => stylingMap[key] || null
8055
8177
  // Effective appearance = the settings card's default, overridden field by
@@ -8078,7 +8200,11 @@ window.__ModuleLoader__.load({
8078
8200
  return Object.keys(style).length > 0 ? style : null
8079
8201
  }
8080
8202
  const keyOf = (kind, payload) => {
8081
- if (kind === 'folder') return 'folder:' + payload.path
8203
+ // Render keys folders by idPath (renderPlainFolder), which prefixes
8204
+ // the ws id to disambiguate same-named groups across disk levels;
8205
+ // saving by payload.path alone made sub-level group styling
8206
+ // unreachable (name-slash group appearance never applied).
8207
+ if (kind === 'folder') return 'folder:' + (payload.idPath || payload.path)
8082
8208
  if (kind === 'workspace') return 'workspace:' + payload.workspaceId
8083
8209
  if (kind === 'session') return 'session:' + payload.id
8084
8210
  if (kind === 'sgroup') return 'sgroup:' + payload.workspaceId + '|' + payload.path
@@ -8112,26 +8238,33 @@ window.__ModuleLoader__.load({
8112
8238
  pinnedPartitionInPlace(ungrouped)
8113
8239
  }
8114
8240
 
8115
- // While a workspace drag is active, single-child chains render UNCOMPRESSED:
8116
- // a merged "group/workspace" row hides every folder level of the chain inside
8117
- // its label, and those levels are exactly the drop targets for "move into
8118
- // this group". Suspending the merge for the drag's duration re-exposes each
8119
- // level as a real folder row (folder expansion defaults apply); when the
8120
- // drag ends, the chains merge back. Session drags keep the merged view —
8121
- // their drop targets live inside workspace rows, which compression merges.
8122
- const draggingWorkspace = drag !== null && drag.kind === 'workspace'
8241
+ // (v0.36.4) The drag-time chain EXPANSION is retired. Suspending the
8242
+ // merge while a workspace was dragged re-exposed every single-child
8243
+ // chain as folder rows, so the tree visibly re-laid itself one frame
8244
+ // after dragstart and every drop target below the source shifted —
8245
+ // the user report "拖拽中树会乱/落点漂移". Drop targets are now
8246
+ // answered by the stable render; nothing re-flows mid-drag.
8123
8247
  const tree = React.useMemo(() => {
8124
- const built = buildTree(items, workspaceGroupMode, wsFolders)
8125
- if (!compactChains || draggingWorkspace) return built
8126
- return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
8127
- }, [items, compactChains, draggingWorkspace, workspaceGroupMode, wsFolders])
8248
+ const built = buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap)
8249
+ if (!compactChains) return built
8250
+ const folders = built.folders.map((f) => materializeChain(compressTree(f)))
8251
+ // v0.36.5: keep the MIXED root sequence across chain compression —
8252
+ // the compressed node stands in for the raw folder it came from, so
8253
+ // a bare workspace stays interleaved where the user put it.
8254
+ const byName = new Map()
8255
+ for (let i = 0; i < built.folders.length; i += 1) byName.set(built.folders[i].name, folders[i])
8256
+ const seq = Array.isArray(built.seq)
8257
+ ? built.seq.map((item) => item.kind === 'folder' ? { kind: 'folder', node: byName.get(item.node.name) || item.node } : item)
8258
+ : built.seq
8259
+ return { ...built, folders, workspaces: built.workspaces, seq }
8260
+ }, [items, compactChains, workspaceGroupMode, wsFolders, folderOrderMap])
8128
8261
  // UNCOMPRESSED tree, for the new-group dialog only. compressTree merges a
8129
8262
  // single-child chain into one row that stands for BOTH a group and the
8130
8263
  // workspace under it — fine for drawing, useless for choosing a
8131
8264
  // container: one half must stay unselectable and the other must be
8132
8265
  // selectable, and a merged row cannot be both. (It also carried no
8133
8266
  // `name`, which is how a lone "E" rendered blank.)
8134
- const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders), [items, workspaceGroupMode, wsFolders])
8267
+ const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap), [items, workspaceGroupMode, wsFolders, folderOrderMap])
8135
8268
  // Disk-layer ancestry for drag semantics (same derivation as buildTree).
8136
8269
  const diskParentMap = React.useMemo(() => diskParentMapOf(items), [items])
8137
8270
 
@@ -8634,11 +8767,72 @@ window.__ModuleLoader__.load({
8634
8767
  }
8635
8768
  }, [drag === null])
8636
8769
 
8770
+ // Geometry helpers shared by both reorder targets. v0.36.4 judges in
8771
+ // v0.36.4 geometry: an open reorder slot (bw-slot-above / -below) pads
8772
+ // the row open by one logical row (padding, so the box grows to two
8773
+ // rows). Judge the pointer against the row's ORIGINAL position, not the
8774
+ // padded box: the original position is the half its content did NOT
8775
+ // slide into — the pad above for a before-slot, the remaining content
8776
+ // box for an after-slot. Judging there keeps a still pointer from
8777
+ // flipping the slot the moment it opens (the reported "抽搐"), while
8778
+ // still letting the pointer cross the original midpoint to switch
8779
+ // direction — the padded half answers null instead of a stale band.
8780
+ const originalBoxOf = (el) => {
8781
+ const rect = el.getBoundingClientRect()
8782
+ const style = getComputedStyle(el)
8783
+ const padTop = parseFloat(style.paddingTop) || 0
8784
+ const padBottom = parseFloat(style.paddingBottom) || 0
8785
+ if (padTop > 0) return { top: rect.top, bottom: rect.top + padTop }
8786
+ if (padBottom > 0) return { top: rect.top, bottom: rect.bottom - padBottom }
8787
+ return { top: rect.top, bottom: rect.bottom }
8788
+ }
8789
+ // null = the pointer sits in the half the open slot vacated: the caller
8790
+ // keeps its current drop target instead of re-deciding on geometry that
8791
+ // only exists because of that slot.
8792
+ // v0.36.5 MIXED LEVEL SEQUENCE. buildTree attaches a `seq` to every
8793
+ // level (group rows and BARE workspace rows interleaved by the
8794
+ // persisted folderOrder, whose tokens may be a group name or
8795
+ // "w:<workspaceId>"); chain-compressed nodes carry no seq and keep the
8796
+ // historical folders-then-workspaces order. Both the renderer and the
8797
+ // reorder commit read the SAME sequence, so what the user sees is what
8798
+ // the drop edits.
8799
+ const levelSeqOf = (level) => (level && Array.isArray(level.seq))
8800
+ ? level.seq
8801
+ : [
8802
+ ...(((level && level.folders) || []).map((node) => ({ kind: 'folder', node }))),
8803
+ ...(((level && level.workspaces) || []).map((entry) => ({ kind: 'ws', entry }))),
8804
+ ]
8805
+ const levelTokenOf = (item) => item.kind === 'ws' ? 'w:' + item.entry.workspaceId : item.node.name
8637
8806
  const rowHalf = (event) => {
8638
- const rect = event.currentTarget.getBoundingClientRect()
8639
- return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
8807
+ const box = originalBoxOf(event.currentTarget)
8808
+ const y = event.clientY
8809
+ if (y < box.top || y > box.bottom) return null
8810
+ return y < (box.top + box.bottom) / 2 ? 'before' : 'after'
8811
+ }
8812
+ // Three-band targeting for group rows (react-arborist style): the
8813
+ // outer 30% of the CONTENT means "reorder against this group", the
8814
+ // middle 40% means "drop into" — so move-into (the framed highlight)
8815
+ // is easy to hit and reorder never swallows the pointer. v0.36.3
8816
+ // widened the bands from a quarter: at 25% they were 8.5px on a 34px
8817
+ // row and practically unhittable (every drop landed "into" instead
8818
+ // of reordering beside).
8819
+ const rowZone = (event) => {
8820
+ const box = originalBoxOf(event.currentTarget)
8821
+ const y = event.clientY
8822
+ if (y < box.top || y > box.bottom) return null
8823
+ const h = box.bottom - box.top
8824
+ if (h < 2) return 'into'
8825
+ const rel = (y - box.top) / h
8826
+ if (rel < 0.3) return 'before'
8827
+ if (rel > 0.7) return 'after'
8828
+ return 'into'
8640
8829
  }
8641
8830
  const dragMatches = (kind) => drag !== null && drag.kind === kind
8831
+ // A refused hover must DROP the highlight it already owns: leaving a
8832
+ // stale {over} in place drew the previous target's INTO frame on a row
8833
+ // that no longer accepts anything (the user reads it as "it will land
8834
+ // here" and the drop then goes elsewhere).
8835
+ const clearOver = (targetKind, target) => setDrag((current) => (current && current.over && current.over.kind === targetKind && current.over.target === target) ? { ...current, over: null } : current)
8642
8836
  const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
8643
8837
  // Session REORDER needs the host insertSessionBefore action; dragging a
8644
8838
  // session onto a sub-group row (a pure rename) stays available without it.
@@ -8650,12 +8844,26 @@ window.__ModuleLoader__.load({
8650
8844
 
8651
8845
  /* --------------------- workspace drag & drop -------------------- */
8652
8846
 
8847
+ // v0.36.5: a workspace row answers the SAME slot for both drag kinds —
8848
+ // a workspace slides between workspace rows, a whole group block slides
8849
+ // beside one (mixed reorder), so the visual is one shared mechanism.
8653
8850
  const wsDropHalf = (workspaceId) => {
8654
- if (!dragMatches('workspace')) return null
8851
+ if (!dragMatches('workspace') && !dragMatches('folder')) return null
8655
8852
  const over = drag.over
8656
8853
  return over && over.kind === 'workspace' && over.target === workspaceId ? over.half : null
8657
8854
  }
8658
- const wsDropInto = (path) => dragMatches('workspace') && drag.over && drag.over.kind === 'folder' && drag.over.target === path
8855
+ // Folder rows light up for a workspace drop (regroup one workspace)
8856
+ // AND a folder drop (whole-group move) alike — the INTO band only:
8857
+ // an edge-band reorder slot draws the official insert line instead.
8858
+ const wsDropInto = (path) => (dragMatches('workspace') || dragMatches('folder')) && drag.over && drag.over.kind === 'folder' && drag.over.target === path && (!drag.over.zone || drag.over.zone === 'into')
8859
+ // v0.36.4: an edge band on a group row answers IMMEDIATELY with the
8860
+ // empty reorder slot (bw-slot-above/below) — the drop position is
8861
+ // visible the moment the pointer enters the band, no intent wait.
8862
+ // v0.36.5: BOTH sources open the slot on a group row. A folder source
8863
+ // reorders the group block; a WORKSPACE source interleaves one bare
8864
+ // workspace row beside the group — the gesture "insert as the first
8865
+ // row" that used to be impossible because every group rendered first.
8866
+ 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
8867
  const workspaceDragEvents = (workspace) => ({
8660
8868
  draggable: !searching && canDragWorkspace,
8661
8869
  onDragStart: (event) => {
@@ -8684,7 +8892,7 @@ window.__ModuleLoader__.load({
8684
8892
  if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
8685
8893
  wsDragArmTimer.current = setTimeout(() => {
8686
8894
  wsDragArmTimer.current = null
8687
- setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId) }, over: null })
8895
+ setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId), levelKey: workspace.levelKey || '' }, over: null })
8688
8896
  }, 0)
8689
8897
  },
8690
8898
  onDragEnd: () => {
@@ -8708,11 +8916,57 @@ window.__ModuleLoader__.load({
8708
8916
  : (current ? { ...current, over: { kind: 'ws-root', target: workspace.workspaceId } } : current))
8709
8917
  return
8710
8918
  }
8919
+ // v0.36.5 MIXED REORDER: a GROUP block can slide beside a bare
8920
+ // workspace row of the same level (the mirror gesture of a
8921
+ // workspace sliding beside a group row). Siblings only — a group
8922
+ // that lives in another container has no position in this level.
8923
+ if (dragMatches('folder')) {
8924
+ const sourceLevel = drag.source.levelKey || ''
8925
+ if (sourceLevel !== (workspace.levelKey || '')) {
8926
+ // v0.36.7 INTO on the OWNING disk row: a group block dropped on
8927
+ // the workspace row that CONTAINS it answers the same framed
8928
+ // INTO a workspace row gets on a group row ("放入这个工作区
8929
+ // 下"). The only position that means without rewriting a path
8930
+ // is the tail of the level it already lives in, so the drop
8931
+ // commits moveFolderToLevelEnd. Every other cross-level target
8932
+ // stays refused: the disk layer is a filesystem fact.
8933
+ if (sourceLevel === (workspace.subPrefix || '')) {
8934
+ event.preventDefault()
8935
+ event.stopPropagation()
8936
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
8937
+ setDrag(current => (current && current.over && current.over.kind === 'ws-level' && current.over.target === workspace.workspaceId)
8938
+ ? current
8939
+ : (current ? { ...current, over: { kind: 'ws-level', target: workspace.workspaceId } } : current))
8940
+ } else {
8941
+ clearOver('workspace', workspace.workspaceId)
8942
+ clearOver('ws-level', workspace.workspaceId)
8943
+ }
8944
+ return
8945
+ }
8946
+ event.preventDefault()
8947
+ event.stopPropagation()
8948
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
8949
+ const half = rowHalf(event)
8950
+ if (half === null) return
8951
+ setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8952
+ ? current
8953
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
8954
+ return
8955
+ }
8711
8956
  if (!dragMatches('workspace')) return
8957
+ // v0.36.4: the SOURCE row is never its own drop target. A reorder
8958
+ // slot pads a row open by one logical row, so the row the drag
8959
+ // started on ends up covering the pointer's first stop — without
8960
+ // this gate the source row swallowed the target, the drop landed on
8961
+ // itself, commitWorkspaceDrop no-oped and the gesture read as
8962
+ // "拖了没反应".
8963
+ if (drag.source && drag.source.workspaceId === workspace.workspaceId) return
8712
8964
  event.preventDefault()
8713
8965
  event.stopPropagation()
8714
8966
  try { event.dataTransfer.dropEffect = 'move' } catch { }
8715
8967
  const half = rowHalf(event)
8968
+ // Pointer in the half the open slot vacated: keep this row's slot.
8969
+ if (half === null) return
8716
8970
  setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8717
8971
  ? current
8718
8972
  : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
@@ -8726,6 +8980,24 @@ window.__ModuleLoader__.load({
8726
8980
  commitSessionMoveOut(workspace.workspaceId)
8727
8981
  return
8728
8982
  }
8983
+ if (dragMatches('folder')) {
8984
+ const sourceLevel = drag.source.levelKey || ''
8985
+ if (sourceLevel !== (workspace.levelKey || '')) {
8986
+ // v0.36.7: the group's OWNING disk row accepts the framed INTO
8987
+ // (see onDragOver) — put the block back at that level's tail.
8988
+ if (sourceLevel === (workspace.subPrefix || '')) {
8989
+ event.preventDefault()
8990
+ event.stopPropagation()
8991
+ commitFolderMoveToLevelEnd(drag.source)
8992
+ }
8993
+ return
8994
+ }
8995
+ event.preventDefault()
8996
+ event.stopPropagation()
8997
+ const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
8998
+ commitLevelReorder({ kind: 'folder', source: drag.source }, { kind: 'workspace', workspace }, half || 'before')
8999
+ return
9000
+ }
8729
9001
  if (!dragMatches('workspace')) return
8730
9002
  event.preventDefault()
8731
9003
  event.stopPropagation()
@@ -8733,21 +9005,214 @@ window.__ModuleLoader__.load({
8733
9005
  commitWorkspaceDrop(workspace, half)
8734
9006
  },
8735
9007
  })
8736
- const folderDropEvents = (path) => ({
9008
+ // A folder row accepts BOTH drag kinds: a workspace drop regroups that
9009
+ // one workspace (commitWorkspaceMoveInto), a folder drop moves the
9010
+ // whole group — every member retitled under the target prefix
9011
+ // (commitFolderMoveInto, the drag twin of submitFolderRename).
9012
+ // Dropping a group onto itself or its own descendant would loop, so
9013
+ // the hover refuses there and the browser shows its "no drop" cursor.
9014
+ const namePathOf = (folderIdPath) => {
9015
+ const cutAt = folderIdPath.lastIndexOf('//')
9016
+ return cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
9017
+ }
9018
+ // idPath = [<wsId>//]*<namePath>: the owner prefix names the disk
9019
+ // level a group renders in. Reordering only makes sense between
9020
+ // groups of the SAME level — across levels a group's position
9021
+ // belongs to its own container.
9022
+ const ownerPrefixOf = (folderIdPath) => {
9023
+ const cutAt = folderIdPath.lastIndexOf('//')
9024
+ return cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
9025
+ }
9026
+ // PARENT container identity of a group row (v0.36.4) — the key its
9027
+ // sibling order is persisted under. The ROOT level of a disk scope
9028
+ // answers the scope prefix ('' at the top level, '<wsId>' one disk
9029
+ // level down); a deeper group answers the idPath of its parent group.
9030
+ const parentKeyOf = (folderIdPath) => {
9031
+ const cutAt = folderIdPath.lastIndexOf('//')
9032
+ const prefix = cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
9033
+ const namePath = cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
9034
+ const segs = namePath.split('/')
9035
+ segs.pop()
9036
+ const parentNamePath = segs.join('/')
9037
+ return parentNamePath === '' ? prefix : prefix + '//' + parentNamePath
9038
+ }
9039
+ // Locate the container a group order list belongs to inside the RAW
9040
+ // (uncompressed) tree: the ROOT level, a disk sub-level (a
9041
+ // workspace's `sub`), or a group node whose `folders` are its
9042
+ // children. Compression never runs on rawTree, so every group the
9043
+ // user can drop beside still has its own idPath here.
9044
+ const folderContainerOf = (key) => {
9045
+ if (key === '') return rawTree
9046
+ let hit = null
9047
+ const walk = (level) => {
9048
+ if (hit || !level) return
9049
+ for (const w of level.workspaces || []) {
9050
+ if (hit) return
9051
+ if (w.subPrefix === key && w.sub) { hit = w.sub; return }
9052
+ if (w.sub) walk(w.sub)
9053
+ }
9054
+ for (const f of level.folders || []) {
9055
+ if (hit) return
9056
+ if (f.idPath === key) { hit = f; return }
9057
+ walk(f)
9058
+ }
9059
+ }
9060
+ walk(rawTree)
9061
+ return hit
9062
+ }
9063
+ // Whole-block member ids of a name group, from this rendering level
9064
+ // down: a reorder slides EVERY nested workspace (prefixes untouched,
9065
+ // so sub-groups travel with the block; only the registry order
9066
+ // shifts).
9067
+ const collectMemberIds = (folderNode) => {
9068
+ const acc = []
9069
+ const walk = (level) => {
9070
+ for (const w of level.workspaces || []) acc.push(w.workspaceId)
9071
+ for (const f of level.folders || []) {
9072
+ if (f.kind === 'ws') acc.push(f.workspace.workspaceId)
9073
+ else walk(f)
9074
+ }
9075
+ }
9076
+ walk(folderNode)
9077
+ return acc
9078
+ }
9079
+ const folderDropEvents = (idPath, path, node) => ({
9080
+ draggable: !searching,
9081
+ onDragStart: (event) => {
9082
+ if (searching) return
9083
+ event.stopPropagation()
9084
+ try {
9085
+ event.dataTransfer.effectAllowed = 'move'
9086
+ event.dataTransfer.setData('text/plain', idPath)
9087
+ } catch { /* drag payload is best-effort */ }
9088
+ // Same one-frame arm as the workspace drag: Chromium cancels a
9089
+ // just-started drag whose source element moves under the cursor.
9090
+ if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
9091
+ wsDragArmTimer.current = setTimeout(() => {
9092
+ wsDragArmTimer.current = null
9093
+ setDrag({ kind: 'folder', source: { idPath, path, memberIds: collectMemberIds(node), levelKey: parentKeyOf(idPath) }, over: null })
9094
+ }, 0)
9095
+ },
9096
+ onDragEnd: () => {
9097
+ if (wsDragArmTimer.current !== null) { clearTimeout(wsDragArmTimer.current); wsDragArmTimer.current = null }
9098
+ setDrag(null)
9099
+ },
8737
9100
  onDragOver: (event) => {
8738
- if (!dragMatches('workspace')) return
9101
+ // Three bands on a group row: the outer 30% bands reorder this
9102
+ // group among its SIBLINGS (an empty slot opens at once — the
9103
+ // drop position is visible the moment the band is entered); the
9104
+ // middle 40% moves the source INSIDE the hovered group (framed,
9105
+ // never opened). Workspace sources keep whole-row move-into
9106
+ // semantics.
9107
+ //
9108
+ // SIBLINGS means the SAME PARENT (parentKeyOf): a band reorder
9109
+ // only permutes one level's group name list, it never rewrites a
9110
+ // title prefix. Cross-parent and self/descendant targets are
9111
+ // refused — no preventDefault, so the platform shows its own
9112
+ // 'no drop' cursor, which is the truth.
9113
+ //
9114
+ // v0.36.4: once THIS row already holds a reorder slot, the whole
9115
+ // original row position answers before/after ONLY (a plain
9116
+ // midpoint split). Judging the three bands again there reintroduced
9117
+ // the INTO band on the very row that just padded open: a pointer
9118
+ // drifting a few pixels inside it flipped to INTO, closed the slot
9119
+ // and read as a flicker ("抽搐") — and worse, the drop then landed
9120
+ // as move-into (a no-op) instead of the reorder the user aimed at.
9121
+ // The three bands still decide the FIRST entry, so INTO (move the
9122
+ // source inside this group) stays reachable.
9123
+ const slotOpen = drag.over !== null && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')
9124
+ const zone = slotOpen ? rowHalf(event) : rowZone(event)
9125
+ const isFolder = dragMatches('folder')
9126
+ const isWorkspace = dragMatches('workspace')
9127
+ if (isFolder) {
9128
+ const targetPath = namePathOf(idPath)
9129
+ // Self / descendant: looping.
9130
+ if (drag.source.path === targetPath || targetPath.startsWith(drag.source.path + '/')) { clearOver('folder', idPath); return }
9131
+ // Ancestor: the group already lives inside. The DIRECT parent is
9132
+ // the container it lives in, so v0.36.7 reopens its MIDDLE band
9133
+ // (the framed INTO): "放进那个组" can only mean "move to the end
9134
+ // of that group", the same feedback a workspace row gets when
9135
+ // dropped INTO its own group. Edge bands still mean nothing
9136
+ // there (leaving a parent is a title rename, not an order
9137
+ // change), and a further ancestor stays refused.
9138
+ if (targetPath !== '' && drag.source.path.startsWith(targetPath + '/')) {
9139
+ const cutAt = drag.source.path.lastIndexOf('/')
9140
+ const directParent = cutAt === -1 ? '' : drag.source.path.slice(0, cutAt)
9141
+ if (targetPath !== directParent || zone !== 'into') { clearOver('folder', idPath); return }
9142
+ }
9143
+ // Pointer in the half the open slot vacated: keep this row's slot
9144
+ // (re-deciding there would flicker the slot it just opened).
9145
+ if (zone === null) {
9146
+ if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
9147
+ event.preventDefault()
9148
+ event.stopPropagation()
9149
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
9150
+ }
9151
+ return
9152
+ }
9153
+ // Edge bands permute ONE level's sibling list; a cross-parent
9154
+ // band is refused. Move-into (the middle band) stays open across
9155
+ // parents: it is a batch rename, which does reach another group.
9156
+ if (zone !== 'into' && parentKeyOf(idPath) !== parentKeyOf(drag.source.idPath)) { clearOver('folder', idPath); return }
9157
+ } else if (!isWorkspace) {
9158
+ return
9159
+ } else {
9160
+ // v0.36.6 LIFT: a bare workspace row slides beside a group of ANY
9161
+ // level it can reach. Same level keeps the v0.36.5 mixed reorder.
9162
+ // Another level is a LIFT: the workspace leaves its current group
9163
+ // and joins the target group's level, inserted right there. That
9164
+ // is the "insert as the FIRST row" gesture the user kept
9165
+ // reporting: the hover used to be refused silently, so {over}
9166
+ // kept the previous frame's INTO, and the drop became a
9167
+ // move-into that left the row last inside the group. A lift may
9168
+ // not cross a DISK boundary (a workspace's folder is a
9169
+ // filesystem fact), so the target level must live in the same
9170
+ // disk scope as the source. The middle band keeps its old
9171
+ // meaning: move the workspace INTO this group.
9172
+ if (zone === null) {
9173
+ if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
9174
+ event.preventDefault()
9175
+ event.stopPropagation()
9176
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
9177
+ }
9178
+ return
9179
+ }
9180
+ if (zone !== 'into' && (drag.source.levelKey || '') !== parentKeyOf(idPath) && (ownerPrefixOf(idPath) || '') !== (drag.source.disk || '')) { clearOver('folder', idPath); return }
9181
+ }
8739
9182
  event.preventDefault()
8740
9183
  event.stopPropagation()
8741
9184
  try { event.dataTransfer.dropEffect = 'move' } catch { }
8742
- setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === path)
9185
+ const reorder = zone !== 'into'
9186
+ const nextOver = reorder
9187
+ ? { kind: 'folder', target: idPath, zone }
9188
+ : { kind: 'folder', target: idPath, zone: 'into' }
9189
+ setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === idPath && current.over.zone === nextOver.zone)
8743
9190
  ? current
8744
- : (current ? { ...current, over: { kind: 'folder', target: path } } : current))
9191
+ : (current ? { ...current, over: nextOver } : current))
8745
9192
  },
8746
9193
  onDrop: (event) => {
9194
+ if (dragMatches('folder')) {
9195
+ event.preventDefault()
9196
+ event.stopPropagation()
9197
+ const zone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath ? drag.over.zone : 'into'
9198
+ if (zone === 'before' || zone === 'after') commitFolderReorder(drag.source, idPath, zone, node)
9199
+ else commitFolderMoveInto(drag.source, idPath)
9200
+ return
9201
+ }
8747
9202
  if (!dragMatches('workspace')) return
8748
9203
  event.preventDefault()
8749
9204
  event.stopPropagation()
8750
- commitWorkspaceMoveInto(path)
9205
+ // The live {over} wins; the geometry is the fallback for a drop that
9206
+ // lands before any hover frame recorded a band on this row.
9207
+ const overZone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after') ? drag.over.zone : null
9208
+ const geoZone = rowZone(event) || 'into'
9209
+ const zone = overZone || geoZone
9210
+ if (zone === 'before' || zone === 'after') {
9211
+ // Same level = plain mixed reorder; another level (same disk
9212
+ // scope) = lift the workspace out of its group into that level.
9213
+ if ((drag.source.levelKey || '') === parentKeyOf(idPath)) commitLevelReorder({ kind: 'workspace', source: drag.source }, { kind: 'folder', target: idPath }, zone)
9214
+ else commitWorkspaceLift(drag.source, idPath, zone)
9215
+ } else commitWorkspaceMoveInto(idPath)
8751
9216
  },
8752
9217
  })
8753
9218
  const nextWorkspaceAfter = (folderPath, workspaceId) => {
@@ -8756,6 +9221,87 @@ window.__ModuleLoader__.load({
8756
9221
  const index = node.workspaces.findIndex(w => w.workspaceId === workspaceId)
8757
9222
  return index === -1 ? undefined : (node.workspaces[index + 1] ? node.workspaces[index + 1].workspaceId : undefined)
8758
9223
  }
9224
+ // v0.36.5 MIXED LEVEL REORDER. One persisted list per level decides the
9225
+ // display order of BOTH kinds: a group name token and a "w:<id>"
9226
+ // workspace token. Writes the list only — the registry keeps its own
9227
+ // (workspace-only) order, because "above that group" is a VIEW position
9228
+ // the registry has no vocabulary for: a workspace title still decides
9229
+ // which group it belongs to, the list only decides where the row sits.
9230
+ // v0.36.7: a group block dropped INTO the container it already lives in
9231
+ // (the framed INTO on its DIRECT parent group row, or on the disk-level
9232
+ // row that owns it) lands at that container's TAIL — the same
9233
+ // "放入=排到最后" a workspace row gets when dropped INTO its own group.
9234
+ // No prefix is rewritten (the block already lives under the right path),
9235
+ // only the level order moves.
9236
+ const moveFolderToLevelEnd = (source) => {
9237
+ const containerKey = source.levelKey || ''
9238
+ const container = folderContainerOf(containerKey)
9239
+ if (!container) { console.warn('[bw] folder move refused: target level not found', containerKey); return false }
9240
+ const token = namePathOf(source.idPath).split('/').pop()
9241
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9242
+ if (!tokens.includes(token)) { console.warn('[bw] folder move refused: group not in this level', token); return false }
9243
+ const next = tokens.filter((entry) => entry !== token)
9244
+ next.push(token)
9245
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, next)
9246
+ return true
9247
+ }
9248
+ const commitFolderMoveToLevelEnd = (source) => {
9249
+ setDrag(null)
9250
+ moveFolderToLevelEnd(source)
9251
+ }
9252
+ const writeLevelOrder = (containerKey, sourceToken, targetToken, zone) => {
9253
+ const container = folderContainerOf(containerKey)
9254
+ if (!container) { console.warn('[bw] level reorder refused: container not found', containerKey); return false }
9255
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9256
+ const at = tokens.indexOf(sourceToken)
9257
+ if (at === -1) { console.warn('[bw] level reorder refused: source not in this level', sourceToken); return false }
9258
+ tokens.splice(at, 1)
9259
+ const targetAt = tokens.indexOf(targetToken)
9260
+ if (targetAt === -1) { console.warn('[bw] level reorder refused: target not in this level', targetToken); return false }
9261
+ tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
9262
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
9263
+ return true
9264
+ }
9265
+ // v0.36.6 LIFT writes: the source token does NOT exist in the target
9266
+ // level yet (that is what a lift is), so writeLevelOrder's
9267
+ // "source must already be here" precondition does not apply — the token
9268
+ // is spliced straight in beside the target group.
9269
+ const insertLevelToken = (containerKey, sourceToken, targetToken, zone) => {
9270
+ const container = folderContainerOf(containerKey)
9271
+ if (!container) { console.warn('[bw] workspace lift refused: target level not found', containerKey); return false }
9272
+ const tokens = levelSeqOf(container).map(levelTokenOf).filter((token) => token !== sourceToken)
9273
+ const targetAt = tokens.indexOf(targetToken)
9274
+ if (targetAt === -1) { console.warn('[bw] workspace lift refused: target group not in this level', targetToken); return false }
9275
+ tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
9276
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
9277
+ return true
9278
+ }
9279
+ // The level a lifted workspace LEFT keeps a stale "w:<id>" token
9280
+ // otherwise (buildTree skips it harmlessly, but the list would grow
9281
+ // forever and a later re-group could resurrect the old position).
9282
+ const removeLevelToken = (containerKey, token) => {
9283
+ const container = folderContainerOf(containerKey)
9284
+ if (!container) return
9285
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9286
+ if (!tokens.includes(token)) return
9287
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens.filter((entry) => entry !== token))
9288
+ }
9289
+ // Shared commit for both mixed directions — a workspace sliding beside a
9290
+ // group row, and a group block sliding beside a workspace row. The
9291
+ // target names the level it belongs to; the hover gate already refused
9292
+ // cross-level targets and this re-checks it as belt and braces.
9293
+ const commitLevelReorder = (sourceRef, targetRef, zone) => {
9294
+ setDrag(null)
9295
+ if (!canDragWorkspace) { console.warn('[bw] level reorder refused: workspace reorder unavailable on this host'); return }
9296
+ const wsSource = sourceRef.kind === 'workspace'
9297
+ const sourceToken = wsSource ? 'w:' + sourceRef.source.workspaceId : namePathOf(sourceRef.source.idPath).split('/').pop()
9298
+ const folderTarget = targetRef.kind === 'folder'
9299
+ const targetToken = folderTarget ? namePathOf(targetRef.target).split('/').pop() : 'w:' + targetRef.workspace.workspaceId
9300
+ const containerKey = folderTarget ? parentKeyOf(targetRef.target) : (targetRef.workspace.levelKey || '')
9301
+ const sourceLevelKey = sourceRef.source.levelKey || ''
9302
+ if (sourceLevelKey !== containerKey) { console.warn('[bw] level reorder refused: cross-level target', sourceLevelKey, containerKey); return }
9303
+ writeLevelOrder(containerKey, sourceToken, targetToken, zone)
9304
+ }
8759
9305
  const commitWorkspaceDrop = (targetWorkspace, half) => {
8760
9306
  const source = drag.source
8761
9307
  setDrag(null)
@@ -8769,6 +9315,16 @@ window.__ModuleLoader__.load({
8769
9315
  // cross-level drop only re-groups the title (the registry append
8770
9316
  // lands it at the end of ITS disk level).
8771
9317
  const sameDisk = source.disk === diskParentMap.get(targetWorkspace.workspaceId)
9318
+ // v0.36.6: a cross-LEVEL drop on a workspace row's half is a PLACEMENT,
9319
+ // not a re-group. The user reported "the insert line shows but the row
9320
+ // lands somewhere else": renaming into the target's group moved the row
9321
+ // into that group's block at whatever position the group already had.
9322
+ // Lifting to the target row's own level and anchoring at that row is
9323
+ // what makes the drop land where the line was drawn.
9324
+ if (workspaceSlash && (targetWorkspace.levelKey || '') !== (source.levelKey || '')) {
9325
+ commitWorkspaceLift(source, targetWorkspace.levelKey || '', half === 'after' ? 'after' : 'before', targetWorkspace.workspaceId)
9326
+ return
9327
+ }
8772
9328
  const chain = sameFolder
8773
9329
  ? Promise.resolve()
8774
9330
  : Promise.resolve().then(() => {
@@ -8782,6 +9338,69 @@ window.__ModuleLoader__.load({
8782
9338
  : targetWorkspace.workspaceId)
8783
9339
  chain
8784
9340
  .then(() => (anchor !== undefined ? insertWorkspaceBefore(source.workspaceId, anchor) : insertWorkspaceBefore(source.workspaceId)))
9341
+ // v0.36.5: once a level carries a MIXED list (it interleaves a bare
9342
+ // workspace with its groups), the registry edit above no longer
9343
+ // moves the row — rewrite the list too, keeping every other token.
9344
+ .then(() => {
9345
+ const levelKey = targetWorkspace.levelKey || ''
9346
+ const container = folderContainerOf(levelKey)
9347
+ const tokens = container ? levelSeqOf(container).map(levelTokenOf) : []
9348
+ if (tokens.some((token) => token.startsWith('w:'))) {
9349
+ writeLevelOrder(levelKey, 'w:' + source.workspaceId, 'w:' + targetWorkspace.workspaceId, half === 'after' ? 'after' : 'before')
9350
+ }
9351
+ })
9352
+ .catch(fail)
9353
+ }
9354
+ // v0.36.6 LIFT. Dropping a bare workspace row on a group's EDGE band
9355
+ // from a DIFFERENT level means: take the row out of its group, give it
9356
+ // the target level's name prefix and place it right beside that group.
9357
+ // The user's report was "I can never insert it as the first row" — the
9358
+ // gesture existed visually but every path landed it inside the group
9359
+ // (move-into) at the tail. The lift is a rename + a level token, never a
9360
+ // disk move, so the caller has already checked the disk scope.
9361
+ const commitWorkspaceLift = (source, targetIdPath, zone, anchorWorkspaceId) => {
9362
+ setDrag(null)
9363
+ if (!canDragWorkspace) { console.warn('[bw] workspace lift refused: workspace reorder unavailable on this host'); return }
9364
+ const targetParentKey = parentKeyOf(targetIdPath)
9365
+ if (!folderContainerOf(targetParentKey)) { console.warn('[bw] workspace lift refused: target level not found', targetParentKey); return }
9366
+ const cutAt = targetParentKey.lastIndexOf('//')
9367
+ const levelNamePath = cutAt === -1 ? targetParentKey : targetParentKey.slice(cutAt + 2)
9368
+ const sourceKey = source.levelKey || ''
9369
+ const targetNamePath = namePathOf(targetIdPath)
9370
+ const targetNode = findTreeNode(rawTree, targetNamePath)
9371
+ const targetMembers = targetNode ? collectMemberIds(targetNode) : []
9372
+ const registryIds = (items || []).map((w) => w.workspaceId)
9373
+ // after = insert before whatever follows the target block in the
9374
+ // registry (undefined = append). before = before the block's head.
9375
+ // A row-anchored lift (dropped on a workspace row's half) anchors at
9376
+ // that row itself, not at the head/tail of its whole group block.
9377
+ let anchor
9378
+ if (anchorWorkspaceId) {
9379
+ if (zone === 'before') {
9380
+ anchor = anchorWorkspaceId
9381
+ } else {
9382
+ const rowAt = registryIds.indexOf(anchorWorkspaceId)
9383
+ anchor = rowAt === -1 ? undefined : registryIds[rowAt + 1]
9384
+ }
9385
+ } else if (zone === 'before') {
9386
+ anchor = targetMembers[0]
9387
+ } else if (targetMembers.length) {
9388
+ const tailAt = registryIds.indexOf(targetMembers[targetMembers.length - 1])
9389
+ anchor = tailAt === -1 ? undefined : registryIds[tailAt + 1]
9390
+ }
9391
+ const newTitle = levelNamePath !== '' ? levelNamePath + '/' + source.leaf : source.leaf
9392
+ const sameNameLevel = (source.folderPath || '') === levelNamePath
9393
+ Promise.resolve()
9394
+ .then(() => (sameNameLevel ? Promise.resolve() : renameWorkspace(source.workspaceId, newTitle)))
9395
+ .then(() => insertWorkspaceBefore(source.workspaceId, anchor))
9396
+ .then(() => {
9397
+ // The level token is always a GROUP token: a workspace row dropped on
9398
+ // from another level is painted beside the group that row lives in,
9399
+ // so the token to place it next to is that group's name in the
9400
+ // parent level's list (the row's own "w:" token lives one level in).
9401
+ insertLevelToken(targetParentKey, 'w:' + source.workspaceId, targetNamePath.split('/').pop(), zone)
9402
+ if (sourceKey !== targetParentKey) removeLevelToken(sourceKey, 'w:' + source.workspaceId)
9403
+ })
8785
9404
  .catch(fail)
8786
9405
  }
8787
9406
  const commitWorkspaceMoveInto = (folderIdPath) => {
@@ -8798,6 +9417,102 @@ window.__ModuleLoader__.load({
8798
9417
  .then(() => insertWorkspaceBefore(source.workspaceId))
8799
9418
  .catch(fail)
8800
9419
  }
9420
+ // Group drag (v0.36.0): moving a folder rewrites EVERY member's title
9421
+ // under the target prefix — the same batch rename the rename-group
9422
+ // dialog (submitFolderRename) performs. Titles are global name paths,
9423
+ // so the member sweep is prefix-based over all items (same known
9424
+ // limitation as that dialog: same-named groups on different disk
9425
+ // levels move together). Self / descendant targets are already
9426
+ // refused at hover time; re-checked here as belt and braces.
9427
+ const commitFolderMoveInto = (source, targetIdPath) => {
9428
+ setDrag(null)
9429
+ const targetPath = namePathOf(targetIdPath)
9430
+ // v0.36.7: the DIRECT parent is the container the group already lives
9431
+ // in. The hover already showed the framed INTO there, so "放进那个
9432
+ // 组" means "move to the end of that group" — no rename involved.
9433
+ // Checked BEFORE the ancestor refusal below.
9434
+ const cutAt = source.path.lastIndexOf('/')
9435
+ const directParent = cutAt === -1 ? '' : source.path.slice(0, cutAt)
9436
+ if (targetPath === directParent) { moveFolderToLevelEnd(source); return }
9437
+ // An ANCESTOR target means the group already lives there: a prefix
9438
+ // rewrite would duplicate a segment (工作/A → 工作/工作/A). Refuse.
9439
+ 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 }
9440
+ const prefix = source.path + '/'
9441
+ const affected = (items || []).filter((w) => String(w.title || '').startsWith(prefix))
9442
+ if (!affected.length) { console.warn('[bw] folder move refused: no members under', source.path); return }
9443
+ Promise.resolve()
9444
+ .then(async () => {
9445
+ for (const w of affected) {
9446
+ await renameWorkspace(w.workspaceId, targetPath + String(w.title).slice(source.path.length))
9447
+ }
9448
+ })
9449
+ // (v0.36.4) NO automatic expand after a move-into. v0.36.3 opened
9450
+ // the target group here so the move would be visible, but on the
9451
+ // real tree a drop in the middle band is exactly the "变下级"
9452
+ // gesture, and auto-opening made that read as "又被撑开了".
9453
+ // The row the user just dropped into stays as it was; the moved
9454
+ // members appear under it once the user opens that group.
9455
+ .catch(fail)
9456
+ }
9457
+ // Group REORDER (v0.36.0): a name group's position IS the registry
9458
+ // order of its members, so reordering = sliding the whole member
9459
+ // block (prefixes untouched) to just before / after the target
9460
+ // group's block. Anchored like commitWorkspaceDrop: before = the
9461
+ // target's first member, after = whatever follows the target's last
9462
+ // member (undefined = block-append at the level tail). Hover only
9463
+ // arms this between same-level groups (identical owner prefix);
9464
+ // re-checked here as belt and braces.
9465
+ const commitFolderReorder = (source, targetIdPath, zone, targetNode) => {
9466
+ setDrag(null)
9467
+ if (!canDragWorkspace) { console.warn('[bw] folder reorder refused: workspace reorder unavailable on this host'); return }
9468
+ if (ownerPrefixOf(source.idPath) !== ownerPrefixOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-level target'); return }
9469
+ // v0.36.4: SIBLINGS ONLY. A group's rendered position is decided by
9470
+ // its parent's order list, so a target that hangs off a different
9471
+ // parent cannot place it at all.
9472
+ if (parentKeyOf(source.idPath) !== parentKeyOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-parent target'); return }
9473
+ const sourceIds = source.memberIds || []
9474
+ if (!sourceIds.length) { console.warn('[bw] folder reorder refused: empty source block'); return }
9475
+ // Whole-block anchors: the target's members include its nested
9476
+ // sub-groups (a plain .workspaces read misses container groups and
9477
+ // silently returned on them — the "drop did nothing" report).
9478
+ // v0.36.3 drag-out: the source block IS part of the target's block
9479
+ // (dropping a nested group onto its parent), so the source subtree
9480
+ // is filtered out before anchoring — otherwise the anchor lands
9481
+ // inside the moving subtree and the drop no-ops or oscillates.
9482
+ const sourceSet = new Set(sourceIds)
9483
+ const targetMembers = collectMemberIds(targetNode).filter((id) => !sourceSet.has(id))
9484
+ if (!targetMembers.length) { console.warn('[bw] folder reorder refused: target has no members to anchor against'); return }
9485
+ // before = the surviving block's first member; after = its last
9486
+ // member (inserting before it = right after the block). Anchoring
9487
+ // on the block tail itself replaces nextWorkspaceAfter, whose
9488
+ // node.workspaces lookup missed nested tails (findIndex -1 →
9489
+ // silent append).
9490
+ const anchor = zone === 'after'
9491
+ ? targetMembers[targetMembers.length - 1]
9492
+ : targetMembers[0]
9493
+ // v0.36.4: THE DISPLAYED ORDER IS PERSISTED HERE. Group order used to
9494
+ // follow buildTree's name sort, so the registry edit below changed
9495
+ // nothing the user could see ("拖了没反应"). folderOrder[parentKey]
9496
+ // is the sibling name list buildTree sorts by; write it first so the
9497
+ // drop is visible immediately, then sync the registry for the
9498
+ // workspace order other surfaces read.
9499
+ const orderParent = parentKeyOf(source.idPath)
9500
+ const sourceName = namePathOf(source.idPath).split('/').pop()
9501
+ const targetName = namePathOf(targetIdPath).split('/').pop()
9502
+ // v0.36.5: write the MIXED token list (group names + w:<id> tokens),
9503
+ // not just the folder names — a level that already interleaves a bare
9504
+ // workspace with its groups must keep those workspace positions.
9505
+ writeLevelOrder(orderParent, sourceName, targetName, zone)
9506
+ Promise.resolve()
9507
+ .then(async () => {
9508
+ if (anchor === undefined) {
9509
+ for (const id of sourceIds) await insertWorkspaceBefore(id)
9510
+ } else {
9511
+ for (const id of sourceIds) await insertWorkspaceBefore(id, anchor)
9512
+ }
9513
+ })
9514
+ .catch(fail)
9515
+ }
8801
9516
 
8802
9517
  /* ---------------------- session drag & drop --------------------- */
8803
9518
 
@@ -8925,6 +9640,10 @@ window.__ModuleLoader__.load({
8925
9640
  .catch(fail)
8926
9641
  }
8927
9642
  const wsRootDropActive = (workspaceId) => dragMatches('session') && drag.over !== null && drag.over.kind === 'ws-root' && drag.over.target === workspaceId
9643
+ // v0.36.7: a group block hovering the disk-level row that OWNS it lights
9644
+ // the SAME framed INTO a workspace row gets on a group row — the visual
9645
+ // is one language: edge bands reorder, the middle band "puts inside".
9646
+ const wsGroupIntoActive = (workspaceId) => dragMatches('folder') && drag.over !== null && drag.over.kind === 'ws-level' && drag.over.target === workspaceId
8928
9647
  const commitSessionMoveInto = (workspaceId, groupPath) => {
8929
9648
  const source = drag.source
8930
9649
  setDrag(null)
@@ -9191,7 +9910,7 @@ window.__ModuleLoader__.load({
9191
9910
  menuOpen: !!(ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId),
9192
9911
  hoverBody: workspaceHoverBody,
9193
9912
  dropHalf: wsDropHalf(workspace.workspaceId),
9194
- dropInto: wsRootDropActive(workspace.workspaceId),
9913
+ dropInto: wsRootDropActive(workspace.workspaceId) || wsGroupIntoActive(workspace.workspaceId),
9195
9914
  dragEvents: workspaceDragEvents(workspace),
9196
9915
  custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
9197
9916
  iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
@@ -9235,8 +9954,11 @@ window.__ModuleLoader__.load({
9235
9954
  : undefined
9236
9955
  const renderLevelRows = (level, depth) => {
9237
9956
  const rows = []
9238
- for (const folder of level.folders) rows.push(...renderPlainFolder(folder, depth))
9239
- for (const workspace of level.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth, wsPulseOf(workspace)))
9957
+ for (const item of levelSeqOf(level)) {
9958
+ rows.push(...(item.kind === 'ws'
9959
+ ? renderWorkspaceEntry({ workspace: item.entry }, depth, wsPulseOf(item.entry))
9960
+ : renderPlainFolder(item.node, depth)))
9961
+ }
9240
9962
  return rows
9241
9963
  }
9242
9964
 
@@ -9257,7 +9979,8 @@ window.__ModuleLoader__.load({
9257
9979
  onToggle: () => { if (!searching) actions.setExpanded(idPath, !expanded) },
9258
9980
  onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name, idPath, scope: node.scope, empty: countWorkspaces(node) === 0 }, e),
9259
9981
  dropInto: wsDropInto(idPath),
9260
- dragEvents: folderDropEvents(idPath),
9982
+ dropSlot: wsFolderSlotOf(idPath),
9983
+ dragEvents: folderDropEvents(idPath, node.path, node),
9261
9984
  custStyle: rowStyleOf('folder:' + idPath),
9262
9985
  iconMode: (styleEntry('folder:' + idPath) || {}).icon || 'solid',
9263
9986
  pulse: expanded ? null : folderPulseOf(node),
@@ -9265,8 +9988,11 @@ window.__ModuleLoader__.load({
9265
9988
  t,
9266
9989
  })]
9267
9990
  if (expanded) {
9268
- for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
9269
- for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
9991
+ for (const item of levelSeqOf(node)) {
9992
+ rows.push(...(item.kind === 'ws'
9993
+ ? renderWorkspaceEntry({ workspace: item.entry }, depth + 1, wsPulseOf(item.entry))
9994
+ : renderPlainFolder(item.node, depth + 1)))
9995
+ }
9270
9996
  }
9271
9997
  return rows
9272
9998
  }
@@ -9321,8 +10047,11 @@ window.__ModuleLoader__.load({
9321
10047
  }
9322
10048
  bodyRows.push(...renderUngroupedBlock())
9323
10049
  } else {
9324
- for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
9325
- for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
10050
+ for (const item of levelSeqOf(tree)) {
10051
+ bodyRows.push(...(item.kind === 'ws'
10052
+ ? renderWorkspaceEntry({ workspace: item.entry }, 0, wsPulseOf(item.entry))
10053
+ : renderPlainFolder(item.node, 0)))
10054
+ }
9326
10055
  bodyRows.push(...renderUngroupedBlock())
9327
10056
  }
9328
10057
  const isEmpty = bodyRows.length === 0
@@ -10195,33 +10924,54 @@ window.__ModuleLoader__.load({
10195
10924
  if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session search failed')
10196
10925
  return result.value
10197
10926
  }
10198
- // dsh 0.1.6-alpha.2 retention contract: rename borrows through
10199
- // sessions.using with an explicit source — the old plain binding(id)
10200
- // only ever saw already-retained sessions, so renaming a closed row
10201
- // reported "unknown session". Fork and open go through the uiWorkspace
10202
- // navigation service: ISessions no longer carries open() at all
10203
- // (navigation belongs to the view owner, replaceMain).
10927
+ // Rename performance (issue #14): sessions.using retains the session and
10928
+ // awaits reference.ready FIRST, so renaming a closed 5MB row stalled ~6s
10929
+ // loading its whole history just to send a 20ms rename RPC (the rename
10930
+ // itself is independent of the transcript). Tiered, cheapest first:
10204
10931
  //
10205
- // Host-era compat (0.1.5-rc.x hosts ship ISessions.binding only — the
10206
- // 0.1.6-alpha.2 contract replaced it with using): calling sessions.using
10207
- // there died with "sessions.using is not a function", which took down
10208
- // every rename path (row dialog, session-group batch, group move). Prefer
10209
- // the new contract, fall back to the old one, and only then fail loud.
10932
+ // 1. Open session → borrow binding directly. On 0.1.6+ hosts binding(id)
10933
+ // only sees retained scopes (service.ts:539 `scopes.get(id)?.binding`,
10934
+ // undefined when closed), so "a binding comes back" IS the open test;
10935
+ // on 0.1.5-rc.x hosts every row is visible, identical to the old
10936
+ // fallback. Same SessionBinding object the using path borrows, so
10937
+ // title projection / list refresh behave exactly the same.
10938
+ // 2. Closed session → sessions.manager.rename(id, title): "Rename a
10939
+ // Session ... without opening its history" (manager.ts:578) — a bare
10940
+ // remote.session.rename plus the title projection. manager is a
10941
+ // TS-private field of ClientSessions (service.ts:245), reachable at
10942
+ // runtime; feature-probed so hosts without it skip the tier.
10943
+ // 3. Fallback → the 0.1.6-alpha.2 retention contract: sessions.using
10944
+ // with an explicit source (slow — loads history — but the only legal
10945
+ // path on hosts predating manager.rename). 0.1.5-rc.x hosts ship
10946
+ // ISessions.binding only; tier 1 already served them. Then fail loud.
10947
+ // Fork and open go through the uiWorkspace navigation service: ISessions
10948
+ // no longer carries open() at all (navigation belongs to the view owner,
10949
+ // replaceMain).
10210
10950
  const renameSession = async (sessionId, title) => {
10951
+ const fail = (result) => new Error(result && result.error ? result.error.message : 'session rename failed')
10952
+ const binding = typeof sessions.binding === 'function' ? sessions.binding(sessionId) : undefined
10953
+ const face = binding ? binding.session : undefined
10954
+ if (face && typeof face.rename === 'function') {
10955
+ const result = await face.rename(title)
10956
+ if (!result || !result.ok) throw fail(result)
10957
+ return
10958
+ }
10959
+ const manager = sessions.manager
10960
+ if (manager && typeof manager.rename === 'function') {
10961
+ const result = await manager.rename(sessionId, title)
10962
+ if (!result || !result.ok) throw fail(result)
10963
+ return
10964
+ }
10211
10965
  if (typeof sessions.using === 'function') {
10212
10966
  const result = await sessions.using(
10213
10967
  sessionId,
10214
10968
  { source: 'workspaceOperation' },
10215
10969
  (reference) => reference.binding.session.rename(title),
10216
10970
  )
10217
- if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
10971
+ if (!result || !result.ok) throw fail(result)
10218
10972
  return
10219
10973
  }
10220
- const binding = typeof sessions.binding === 'function' ? sessions.binding(sessionId) : undefined
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')
10974
+ throw new Error('session rename is unavailable on this host')
10225
10975
  }
10226
10976
  const forkSession = (sessionId) => {
10227
10977
  uiWorkspace.forkSession(sessionId)