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 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.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.35.1",
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
- const sortRec = (node) => {
4568
- node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
4569
- for (const child of node.folders) sortRec(child)
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
- className: cls('bw-row', 'bw-container-row', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
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
- 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'),
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
- const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
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
- if (kind === 'folder') return 'folder:' + payload.path
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
- // 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'
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 || draggingWorkspace) return built
8126
- return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
8127
- }, [items, compactChains, draggingWorkspace, workspaceGroupMode, wsFolders])
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 rect = event.currentTarget.getBoundingClientRect()
8639
- return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
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
- const wsDropInto = (path) => dragMatches('workspace') && drag.over && drag.over.kind === 'folder' && drag.over.target === path
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
- const folderDropEvents = (path) => ({
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
- if (!dragMatches('workspace')) return
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
- setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === path)
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: { kind: 'folder', target: path } } : current))
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
- commitWorkspaceMoveInto(path)
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 folder of level.folders) rows.push(...renderPlainFolder(folder, depth))
9239
- for (const workspace of level.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth, wsPulseOf(workspace)))
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
- dragEvents: folderDropEvents(idPath),
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 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)))
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
- return rows.map((s) => renderSessionRow({ ...s, leaf: s.title }, 0, undefined))
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 folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
9325
- for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
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
- // 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).
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
- // 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.
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 new Error(result && result.error ? result.error.message : 'session rename failed')
10979
+ if (!result || !result.ok) throw fail(result)
10218
10980
  return
10219
10981
  }
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')
10982
+ throw new Error('session rename is unavailable on this host')
10225
10983
  }
10226
10984
  const forkSession = (sessionId) => {
10227
10985
  uiWorkspace.forkSession(sessionId)