dsh-better-workspace 0.36.9 → 0.36.11

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 序),拖到分组行中带则移动进该分组(改写名称前缀);**工作区分组行同样可拖拽(v0.36.0 起,三带悬停)**——拖到分组行时行分三个落区:上下 **30% 边带**=与该组**排序**(拖完立即重排、刷新后保留),**中带**=整组移入(组成员逐个改写名称前缀,与「重命名分组」同一语义;拖到自身/自己的子孙组上会被拒绝);**组行与工作区行同处一条混合序列(v0.36.5 起)**:一个层级里的行顺序只有一份(组行与裸工作区行交错),工作区可以拖到分组行的边带插到该组前面/后面,分组也可以拖到工作区行前面/后面——不再有「组永远排在裸工作区之前」的限制;拖到**另一层**的边带是**放置**(v0.36.6):工作区离开原组(或进入目标层)并插到落点,「插到第一个」画哪落哪(跨磁盘层不放行——磁盘层是文件系统事实);**拖入黑框统一(v0.36.7)**:工作区行拖到分组行中带 = 改名前缀进组(黑框反馈);组行拖到**它所属的磁盘层工作区行 / 直接父级分组行**中带 = 整组「放入」该容器、排到该层末尾(标题与磁盘位置都不动,同一套黑框视觉)——排序走边带、放入走中带,两类操作视觉上彻底分开;**容器防环(v0.36.9)**:磁盘层的容器工作区不能被拖进它自己的名称组或子工作区——那会把容器改名成自己的子孙(如「工作/用户目录」甚至「<id>/用户目录」),现在整行拒绝、不留任何落点反馈,排序与放入父容器(v0.36.7)不受影响;**撑开让位(v0.36.4)**:进入排序边带立刻把目标行撑开一行(工作区行与分组行同一套视觉),判定按**行未撑开时的原始位置**算——指针停在槽空出的新区里保持当前方向不闪烁;槽已打开的行只按原始中线二选一(上半=插在前,下半=插在后,**不会翻成「移入」**),三带里的中带 INTO 只在首次进入时决定,随时可改主意;会话行可拖拽——同组内拖到另一会话上/下即调整顺序,拖到**别组的会话行**会把名字前缀改写到那个组(拖到根层的行 = 移出分组),拖到会话子分组行即移动进该分组,拖到**自己的工作区行**或**树里任何组外的地方**(含空白区域)同样移出分组 —— 拖拽期间工作区行整行高亮、树底部有一条淡虚线提示这个出口;带分组前缀的会话行右键还多一项「移出分组」。显示顺序 = 宿主手动序(分组行 = 父容器下的持久化组序),拖拽结果立即生效。**磁盘层是文件系统事实,不随拖拽改变**:跨磁盘层不放行(边带不接管光标、不画落点线),同磁盘层内按落点锚定。拖拽期间树形保持不变(v0.36.4):单链合并行不再临时展开、归组成功也不自动展开目标组——指针下的行不会突然换人,展开状态只在点击时改变。
72
+ - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行中带则移动进该分组(改写名称前缀);**工作区分组行同样可拖拽(v0.36.0 起,三带悬停)**——拖到分组行时行分三个落区:上下 **30% 边带**=与该组**排序**(拖完立即重排、刷新后保留),**中带**=整组移入(组成员逐个改写名称前缀,与「重命名分组」同一语义;拖到自身/自己的子孙组上会被拒绝);**组行与工作区行同处一条混合序列(v0.36.5 起)**:一个层级里的行顺序只有一份(组行与裸工作区行交错),工作区可以拖到分组行的边带插到该组前面/后面,分组也可以拖到工作区行前面/后面——不再有「组永远排在裸工作区之前」的限制;拖到**另一层**的边带是**放置**(v0.36.6):工作区离开原组(或进入目标层)并插到落点,「插到第一个」画哪落哪(跨磁盘层不放行——磁盘层是文件系统事实);**拖入黑框统一(v0.36.7)**:工作区行拖到分组行中带 = 改名前缀进组(黑框反馈);组行拖到**它所属的磁盘层工作区行 / 直接父级分组行**中带 = 整组「放入」该容器、排到该层末尾(标题与磁盘位置都不动,同一套黑框视觉)——排序走边带、放入走中带,两类操作视觉上彻底分开;**容器防环(v0.36.9)**:磁盘层的容器工作区不能被拖进它自己的名称组或子工作区——那会把容器改名成自己的子孙(如「工作/用户目录」甚至「<id>/用户目录」),现在整行拒绝、不留任何落点反馈,排序与放入父容器(v0.36.7)不受影响;**平级组嵌套与提升(v0.36.10)**:组行拖到平级组行的**中带**=变成它的**子组**(成员标题落到新组之下,`工作/平行帮` → `工作/Fis管理后台/平行帮`,天然不撞名——旧的并入语义会在双方都有同名子工作区时报 name-conflict、整树不动);组行拖到**另一层工作区行的边带**(同磁盘、不在自己子树内)=**整组提升**到那行所在层并锚定在落点旁(「把平行帮拖到和用户目录、工作平级」);已打开的排序槽不再锁死判定——指针推回行**中带**即可翻成放入黑框,嵌套与排序随时互切;**静止松手兜底**:Chromium 把指针静止约一秒后的松手吞成 dragend 时,按最后悬停的落点补提交(Esc 取消不受影响),不再「松手没反应」;**撑开让位(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 宿主弹应用内入口提示),按钮文案还会带上宿主当前的键位;③ 首次使用的工作区显示为本地化的「默认工作区」,不再露出磁盘目录名。
@@ -129,7 +129,7 @@ DSH 的侧边栏浏览区是一个公开插槽 `sidebar.workspaces`(single),官
129
129
 
130
130
  - 会话**跨工作区**拖拽仍被拒绝(会话归属工作区);同一工作区内的分组归属已随拖拽生效(v0.18.0:拖到别组的行改写前缀,拖到自己的工作区行移出分组,右键也有「移出分组」)。
131
131
  - 搜索为本地标题过滤(两层扁平化后逐工作区匹配);宿主内容搜索(`session.search`)计划接回。
132
- - flat(平铺)视图未接管,层级树即视图。侧栏切到「单列表 / 按工作区」时,新会话页的选择器退化为**一层完整标题**的工作区列表(仍可选,不再分组)。
132
+ - flat(平铺)视图未接管,层级树即视图。侧栏切到「单列表」时,新会话页的选择器退化为**一层完整标题**的工作区列表(仍可选,不再分组);切到「按工作区」时(v0.36.11 起),只要「工作区分组」开着(磁盘+名称 / 仅名称),侧栏与新会话页选择器都显示**名称树挂顶层**的结构(名称组与磁盘容器平级,切换即时生效);「工作区分组」设为「磁盘目录」时保持官方的散开平铺。
133
133
  - 新会话页选择器里的分组开合是**选择器本地状态**(默认全展开):它不写侧栏的折叠状态,免得选工作区时被侧栏的折叠挡住目标。
134
134
 
135
135
  ## 开发
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.36.9",
3
+ "version": "0.36.11",
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.36.9",
3
+ "version": "0.36.11",
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
@@ -8014,6 +8014,21 @@ window.__ModuleLoader__.load({
8014
8014
  // source element, and Chromium cancels the whole gesture. dragEnd clears
8015
8015
  // the timer so a same-tick cancel never leaves a ghost drag behind.
8016
8016
  const wsDragArmTimer = React.useRef(null)
8017
+ // v0.36.10: Chromium CANCELS a drag whose pointer rests long enough —
8018
+ // the mouse-up arrives as dragleave+dragend and 'drop' never fires
8019
+ // (probe-measured: resting ~1-2s loses the drop every time). The
8020
+ // user reads that as "松手没反应". dragStateRef mirrors {drag} for a
8021
+ // dragEnd fallback that honors the armed {over}; dragDroppedRef
8022
+ // tells dragEnd a real drop already committed; Escape still cancels.
8023
+ const dragStateRef = React.useRef(null)
8024
+ const dragDroppedRef = React.useRef(false)
8025
+ const dragCancelledRef = React.useRef(false)
8026
+ React.useEffect(() => { dragStateRef.current = drag }, [drag])
8027
+ React.useEffect(() => {
8028
+ const onKey = (event) => { if (event.key === 'Escape') dragCancelledRef.current = true }
8029
+ window.addEventListener('keydown', onKey, true)
8030
+ return () => window.removeEventListener('keydown', onKey, true)
8031
+ }, [])
8017
8032
  // (v0.36.3) The 600ms group-hover auto-expand is retired: it kept
8018
8033
  // opening groups under a passing cursor ("变下级会被撑开"). The
8019
8034
  // deterministic reveal now lives in commitFolderMoveInto — the target
@@ -8258,7 +8273,15 @@ window.__ModuleLoader__.load({
8258
8273
  // the user report "拖拽中树会乱/落点漂移". Drop targets are now
8259
8274
  // answered by the stable render; nothing re-flows mid-drag.
8260
8275
  const tree = React.useMemo(() => {
8261
- const built = buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap)
8276
+ // v0.36.11: "workspaces" (groupBy === 'workspace') turns OFF disk
8277
+ // nesting — official semantics — but the NAME tree stays on when the
8278
+ // user enabled one (workspaceGroupMode !== 'disk'). A plain buildTree
8279
+ // mode of 'slash' expresses exactly that: name groups at the root,
8280
+ // every workspace at the top, entry.sub always null.
8281
+ const treeMode = groupBy === 'workspace' && workspaceGroupMode !== 'disk'
8282
+ ? 'slash'
8283
+ : workspaceGroupMode
8284
+ const built = buildTree(items, treeMode, wsFolders, folderOrderMap)
8262
8285
  if (!compactChains) return built
8263
8286
  const folders = built.folders.map((f) => materializeChain(compressTree(f)))
8264
8287
  // v0.36.5: keep the MIXED root sequence across chain compression —
@@ -8270,14 +8293,21 @@ window.__ModuleLoader__.load({
8270
8293
  ? built.seq.map((item) => item.kind === 'folder' ? { kind: 'folder', node: byName.get(item.node.name) || item.node } : item)
8271
8294
  : built.seq
8272
8295
  return { ...built, folders, workspaces: built.workspaces, seq }
8273
- }, [items, compactChains, workspaceGroupMode, wsFolders, folderOrderMap])
8296
+ }, [items, compactChains, groupBy, workspaceGroupMode, wsFolders, folderOrderMap])
8274
8297
  // UNCOMPRESSED tree, for the new-group dialog only. compressTree merges a
8275
8298
  // single-child chain into one row that stands for BOTH a group and the
8276
8299
  // workspace under it — fine for drawing, useless for choosing a
8277
8300
  // container: one half must stay unselectable and the other must be
8278
8301
  // selectable, and a merged row cannot be both. (It also carried no
8279
8302
  // `name`, which is how a lone "E" rendered blank.)
8280
- const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap), [items, workspaceGroupMode, wsFolders, folderOrderMap])
8303
+ // v0.36.11: same groupBy-aware mode as the rendered tree — the
8304
+ // new-group dialog must mirror the hierarchy the user actually sees.
8305
+ const rawTree = React.useMemo(() => buildTree(
8306
+ items,
8307
+ groupBy === 'workspace' && workspaceGroupMode !== 'disk' ? 'slash' : workspaceGroupMode,
8308
+ wsFolders,
8309
+ folderOrderMap,
8310
+ ), [items, groupBy, workspaceGroupMode, wsFolders, folderOrderMap])
8281
8311
  // Disk-layer ancestry for drag semantics (same derivation as buildTree).
8282
8312
  const diskParentMap = React.useMemo(() => diskParentMapOf(items), [items])
8283
8313
 
@@ -8604,7 +8634,10 @@ window.__ModuleLoader__.load({
8604
8634
  // hierarchy to mirror, so they fall back to a flat workspace list.
8605
8635
  // Offering a nesting the sidebar is not showing would be a lie about
8606
8636
  // where the new group lands.
8637
+ // v0.36.11: "workspaces" + an enabled name layer shows a root-level
8638
+ // name tree — mirror it instead of claiming there is no hierarchy.
8607
8639
  const treeView = groupBy === 'workspace-tree'
8640
+ || (groupBy === 'workspace' && workspaceGroupMode !== 'disk')
8608
8641
  if (kind === 'workspace') {
8609
8642
  // "Top level" IS a real target here (a group at the root of the
8610
8643
  // workspace tree), so it is a node like any other.
@@ -8832,6 +8865,18 @@ window.__ModuleLoader__.load({
8832
8865
  if (rel > 0.7) return 'after'
8833
8866
  return 'into'
8834
8867
  }
8868
+ // v0.36.10: TRUE when the pointer sits in the row's ORIGINAL content
8869
+ // box (a possibly-open slot's padding excluded). While this row holds
8870
+ // a reorder slot the padding band keeps the slot verdict, and the
8871
+ // content box re-judges the three bands — that is what keeps the
8872
+ // INTO middle reachable after a slot opened. The old slotOpen lock
8873
+ // answered before/after forever, so a pointer that entered through an
8874
+ // edge band could never reach the middle: "拖到另一个下面(嵌套)" was
8875
+ // unreachable through the natural approach path.
8876
+ const pointerInRowBox = (event) => {
8877
+ const box = originalBoxOf(event.currentTarget)
8878
+ return event.clientY >= box.top && event.clientY <= box.bottom
8879
+ }
8835
8880
  const dragMatches = (kind) => drag !== null && drag.kind === kind
8836
8881
  // A refused hover must DROP the highlight it already owns: leaving a
8837
8882
  // stale {over} in place drew the previous target's INTO frame on a row
@@ -8902,7 +8947,23 @@ window.__ModuleLoader__.load({
8902
8947
  },
8903
8948
  onDragEnd: () => {
8904
8949
  if (wsDragArmTimer.current !== null) { clearTimeout(wsDragArmTimer.current); wsDragArmTimer.current = null }
8950
+ const committed = dragDroppedRef.current
8951
+ dragDroppedRef.current = false
8952
+ const cancelled = dragCancelledRef.current
8953
+ dragCancelledRef.current = false
8954
+ const live = dragStateRef.current
8905
8955
  setDrag(null)
8956
+ // v0.36.10: same resting-drag fallback as the folder dragEnd.
8957
+ if (committed || cancelled || !live || live.kind !== 'workspace' || !live.over) return
8958
+ const over = live.over
8959
+ if (over.kind === 'folder') {
8960
+ const zone = over.zone === 'before' || over.zone === 'after' ? over.zone : 'into'
8961
+ if (zone === 'into') commitWorkspaceMoveInto(live.source, over.target)
8962
+ else if ((live.source.levelKey || '') === parentKeyOf(over.target)) commitLevelReorder({ kind: 'workspace', source: live.source }, { kind: 'folder', target: over.target }, zone)
8963
+ else commitWorkspaceLift(live.source, over.target, zone)
8964
+ } else if (over.kind === 'workspace' && over.half && over.workspace) {
8965
+ commitWorkspaceDrop(over.workspace, over.half)
8966
+ }
8906
8967
  },
8907
8968
  onDragOver: (event) => {
8908
8969
  // A SESSION dragged onto the workspace row lands at the root level of
@@ -8943,8 +9004,28 @@ window.__ModuleLoader__.load({
8943
9004
  ? current
8944
9005
  : (current ? { ...current, over: { kind: 'ws-level', target: workspace.workspaceId } } : current))
8945
9006
  } else {
8946
- clearOver('workspace', workspace.workspaceId)
8947
- clearOver('ws-level', workspace.workspaceId)
9007
+ // v0.36.10 GROUP LIFT: an EDGE band on a cross-level row of
9008
+ // the SAME disk scope lifts the whole group out to this
9009
+ // row's level, anchored at the row — "把平行帮拖到和用户
9010
+ // 目录、工作平级". The row must not live inside the dragged
9011
+ // group (that would nest the group under itself) and not on
9012
+ // another disk (a title rename cannot cross disks).
9013
+ const half = rowHalf(event)
9014
+ const sourceScope = diskScopeOf(drag.source.idPath)
9015
+ const targetDisk = diskParentMap.get(workspace.workspaceId) || workspace.workspaceId
9016
+ const targetLevel = workspace.levelKey || ''
9017
+ const insideSource = targetLevel === drag.source.idPath || targetLevel.startsWith(drag.source.idPath + '/')
9018
+ if (half !== null && sourceScope !== null && targetDisk === sourceScope && !insideSource) {
9019
+ event.preventDefault()
9020
+ event.stopPropagation()
9021
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
9022
+ setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
9023
+ ? current
9024
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half, workspace, folderLift: true } } : current))
9025
+ } else {
9026
+ clearOver('workspace', workspace.workspaceId)
9027
+ clearOver('ws-level', workspace.workspaceId)
9028
+ }
8948
9029
  }
8949
9030
  return
8950
9031
  }
@@ -8953,9 +9034,11 @@ window.__ModuleLoader__.load({
8953
9034
  try { event.dataTransfer.dropEffect = 'move' } catch { }
8954
9035
  const half = rowHalf(event)
8955
9036
  if (half === null) return
9037
+ // The row object rides along for the dragEnd fallback (same
9038
+ // reason as the folder over's node above).
8956
9039
  setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8957
9040
  ? current
8958
- : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
9041
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half, workspace } } : current))
8959
9042
  return
8960
9043
  }
8961
9044
  if (!dragMatches('workspace')) return
@@ -8978,7 +9061,7 @@ window.__ModuleLoader__.load({
8978
9061
  if (half === null) return
8979
9062
  setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8980
9063
  ? current
8981
- : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
9064
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half, workspace } } : current))
8982
9065
  },
8983
9066
  onDrop: (event) => {
8984
9067
  if (dragMatches('session')) {
@@ -8997,12 +9080,23 @@ window.__ModuleLoader__.load({
8997
9080
  if (sourceLevel === (workspace.subPrefix || '')) {
8998
9081
  event.preventDefault()
8999
9082
  event.stopPropagation()
9083
+ dragDroppedRef.current = true
9000
9084
  commitFolderMoveToLevelEnd(drag.source)
9085
+ } else {
9086
+ // v0.36.10: the lift band armed in onDragOver.
9087
+ const over = drag.over
9088
+ if (over && over.kind === 'workspace' && over.target === workspace.workspaceId && over.folderLift && over.half) {
9089
+ event.preventDefault()
9090
+ event.stopPropagation()
9091
+ dragDroppedRef.current = true
9092
+ commitFolderLift(drag.source, workspace, over.half)
9093
+ }
9001
9094
  }
9002
9095
  return
9003
9096
  }
9004
9097
  event.preventDefault()
9005
9098
  event.stopPropagation()
9099
+ dragDroppedRef.current = true
9006
9100
  const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
9007
9101
  commitLevelReorder({ kind: 'folder', source: drag.source }, { kind: 'workspace', workspace }, half || 'before')
9008
9102
  return
@@ -9013,6 +9107,7 @@ window.__ModuleLoader__.load({
9013
9107
  if (isOwnDescendantScope(diskParentMap.get(workspace.workspaceId), drag.source.workspaceId)) return
9014
9108
  event.preventDefault()
9015
9109
  event.stopPropagation()
9110
+ dragDroppedRef.current = true
9016
9111
  const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
9017
9112
  commitWorkspaceDrop(workspace, half)
9018
9113
  },
@@ -9132,7 +9227,29 @@ window.__ModuleLoader__.load({
9132
9227
  },
9133
9228
  onDragEnd: () => {
9134
9229
  if (wsDragArmTimer.current !== null) { clearTimeout(wsDragArmTimer.current); wsDragArmTimer.current = null }
9230
+ const committed = dragDroppedRef.current
9231
+ dragDroppedRef.current = false
9232
+ const cancelled = dragCancelledRef.current
9233
+ dragCancelledRef.current = false
9234
+ const live = dragStateRef.current
9135
9235
  setDrag(null)
9236
+ // v0.36.10: no drop AND not cancelled means Chromium killed a
9237
+ // RESTING drag (mouse-up as dragleave+dragend, drop never fires).
9238
+ // Honor the armed {over} instead of throwing the gesture away —
9239
+ // the user aimed at a visible slot/into for seconds. Escape still
9240
+ // cancels for real.
9241
+ if (committed || cancelled || !live || live.kind !== 'folder' || !live.over) return
9242
+ const over = live.over
9243
+ if (over.kind === 'folder') {
9244
+ if (over.zone === 'before' || over.zone === 'after') commitFolderReorder(live.source, over.target, over.zone, over.node)
9245
+ else commitFolderMoveInto(live.source, over.target)
9246
+ } else if (over.kind === 'ws-level') {
9247
+ commitFolderMoveToLevelEnd(live.source)
9248
+ } else if (over.kind === 'workspace' && over.half && over.folderLift && over.workspace) {
9249
+ commitFolderLift(live.source, over.workspace, over.half)
9250
+ } else if (over.kind === 'workspace' && over.half && over.workspace) {
9251
+ commitLevelReorder({ kind: 'folder', source: live.source }, { kind: 'workspace', workspace: over.workspace }, over.half)
9252
+ }
9136
9253
  },
9137
9254
  onDragOver: (event) => {
9138
9255
  // Three bands on a group row: the outer 30% bands reorder this
@@ -9158,7 +9275,13 @@ window.__ModuleLoader__.load({
9158
9275
  // The three bands still decide the FIRST entry, so INTO (move the
9159
9276
  // source inside this group) stays reachable.
9160
9277
  const slotOpen = drag.over !== null && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')
9161
- const zone = slotOpen ? rowHalf(event) : rowZone(event)
9278
+ // v0.36.10: an open slot no longer locks the verdict. The slot's
9279
+ // padding band keeps the slot (rowHalf is null there and the
9280
+ // zone-null branch below holds), but the ORIGINAL content box
9281
+ // re-judges the three bands — the INTO middle stays reachable.
9282
+ const zone = slotOpen
9283
+ ? (pointerInRowBox(event) ? rowZone(event) : rowHalf(event))
9284
+ : rowZone(event)
9162
9285
  const isFolder = dragMatches('folder')
9163
9286
  const isWorkspace = dragMatches('workspace')
9164
9287
  if (isFolder) {
@@ -9226,9 +9349,12 @@ window.__ModuleLoader__.load({
9226
9349
  event.stopPropagation()
9227
9350
  try { event.dataTransfer.dropEffect = 'move' } catch { }
9228
9351
  const reorder = zone !== 'into'
9352
+ // The row's node rides along: the dragEnd fallback (a Chromium
9353
+ // resting drag that never fires drop) commits from {over} alone
9354
+ // and needs it for the target-block anchor.
9229
9355
  const nextOver = reorder
9230
- ? { kind: 'folder', target: idPath, zone }
9231
- : { kind: 'folder', target: idPath, zone: 'into' }
9356
+ ? { kind: 'folder', target: idPath, zone, node }
9357
+ : { kind: 'folder', target: idPath, zone: 'into', node }
9232
9358
  setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === idPath && current.over.zone === nextOver.zone)
9233
9359
  ? current
9234
9360
  : (current ? { ...current, over: nextOver } : current))
@@ -9237,6 +9363,7 @@ window.__ModuleLoader__.load({
9237
9363
  if (dragMatches('folder')) {
9238
9364
  event.preventDefault()
9239
9365
  event.stopPropagation()
9366
+ dragDroppedRef.current = true
9240
9367
  const zone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath ? drag.over.zone : 'into'
9241
9368
  if (zone === 'before' || zone === 'after') commitFolderReorder(drag.source, idPath, zone, node)
9242
9369
  else commitFolderMoveInto(drag.source, idPath)
@@ -9248,6 +9375,7 @@ window.__ModuleLoader__.load({
9248
9375
  if (isOwnDescendantScope(diskScopeOf(idPath), drag.source.workspaceId)) return
9249
9376
  event.preventDefault()
9250
9377
  event.stopPropagation()
9378
+ dragDroppedRef.current = true
9251
9379
  // The live {over} wins; the geometry is the fallback for a drop that
9252
9380
  // lands before any hover frame recorded a band on this row.
9253
9381
  const overZone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after') ? drag.over.zone : null
@@ -9470,6 +9598,47 @@ window.__ModuleLoader__.load({
9470
9598
  // limitation as that dialog: same-named groups on different disk
9471
9599
  // levels move together). Self / descendant targets are already
9472
9600
  // refused at hover time; re-checked here as belt and braces.
9601
+ // v0.36.10 GROUP LIFT. Dropping a group row on a cross-level
9602
+ // workspace row's EDGE band (same disk scope) means: take the whole
9603
+ // group out of its parent, give it the target level's name prefix and
9604
+ // place it beside that row — "把平行帮拖到和用户目录、工作平级".
9605
+ // Mirrors commitWorkspaceLift: member renames plus a level token,
9606
+ // never a disk move; the hover gate already checked scope and nesting.
9607
+ const commitFolderLift = (source, workspace, zone) => {
9608
+ setDrag(null)
9609
+ if (!canDragWorkspace) { console.warn('[bw] folder lift refused: workspace reorder unavailable on this host'); return }
9610
+ const targetLevel = workspace.levelKey || ''
9611
+ if (targetLevel === source.idPath || targetLevel.startsWith(source.idPath + '/')) { console.warn('[bw] folder lift refused: target level inside the source', targetLevel); return }
9612
+ const sourceLeaf = namePathOf(source.idPath).split('/').pop()
9613
+ // The target level's name prefix: '' = the disk root (the group
9614
+ // becomes a plain top-level group), otherwise the level key's name
9615
+ // path after the owner separator.
9616
+ const cutAt = targetLevel.lastIndexOf('//')
9617
+ const levelNamePath = cutAt === -1 ? '' : targetLevel.slice(cutAt + 2)
9618
+ const newSourcePath = levelNamePath === '' ? sourceLeaf : levelNamePath + '/' + sourceLeaf
9619
+ const prefix = source.path + '/'
9620
+ const affected = (items || []).filter((w) => String(w.title || '').startsWith(prefix))
9621
+ if (!affected.length) { console.warn('[bw] folder lift refused: no members under', source.path); return }
9622
+ Promise.resolve()
9623
+ .then(async () => {
9624
+ for (const w of affected) {
9625
+ await renameWorkspace(w.workspaceId, newSourcePath + String(w.title).slice(source.path.length))
9626
+ }
9627
+ })
9628
+ .then(() => {
9629
+ removeLevelToken(source.levelKey || '', sourceLeaf)
9630
+ insertLevelToken(targetLevel, sourceLeaf, 'w:' + workspace.workspaceId, zone === 'after' ? 'after' : 'before')
9631
+ // Carry the group's own member order across to its NEW level
9632
+ // key, so the members keep their inside order after the rename.
9633
+ const oldContainer = folderContainerOf(source.idPath)
9634
+ if (oldContainer && typeof actions.setFolderOrder === 'function') {
9635
+ const owner = ownerPrefixOf(source.idPath)
9636
+ const newKey = owner === '' ? newSourcePath : owner + '//' + newSourcePath
9637
+ if (newKey !== source.idPath) actions.setFolderOrder(newKey, levelSeqOf(oldContainer).map(levelTokenOf))
9638
+ }
9639
+ })
9640
+ .catch(fail)
9641
+ }
9473
9642
  const commitFolderMoveInto = (source, targetIdPath) => {
9474
9643
  setDrag(null)
9475
9644
  const targetPath = namePathOf(targetIdPath)
@@ -9486,10 +9655,37 @@ window.__ModuleLoader__.load({
9486
9655
  const prefix = source.path + '/'
9487
9656
  const affected = (items || []).filter((w) => String(w.title || '').startsWith(prefix))
9488
9657
  if (!affected.length) { console.warn('[bw] folder move refused: no members under', source.path); return }
9658
+ // v0.36.10 NEST semantics: the group keeps its identity UNDER the
9659
+ // target (工作/平行帮 → 工作/Fis管理后台/平行帮). The old prefix
9660
+ // swap merged the members straight into the target's own level —
9661
+ // "平行帮/Web" became "Fis管理后台/Web", colliding with the
9662
+ // target's own Web and dying on workspace/name-conflict before
9663
+ // anything moved: the "平级的没法拖动" report. Nesting names every
9664
+ // member BELOW the moved group, which cannot collide with the
9665
+ // target's direct children.
9666
+ const sourceLeaf = namePathOf(source.idPath).split('/').pop()
9667
+ const newSourcePath = targetPath + '/' + sourceLeaf
9489
9668
  Promise.resolve()
9490
9669
  .then(async () => {
9491
9670
  for (const w of affected) {
9492
- await renameWorkspace(w.workspaceId, targetPath + String(w.title).slice(source.path.length))
9671
+ await renameWorkspace(w.workspaceId, newSourcePath + String(w.title).slice(source.path.length))
9672
+ }
9673
+ })
9674
+ .then(() => {
9675
+ // The group token moves from the source's level to INSIDE the
9676
+ // target group, and the member order follows to the new key.
9677
+ removeLevelToken(source.levelKey || '', sourceLeaf)
9678
+ const targetContainer = folderContainerOf(targetIdPath)
9679
+ if (targetContainer) {
9680
+ const tokens = levelSeqOf(targetContainer).map(levelTokenOf)
9681
+ if (tokens.length) insertLevelToken(targetIdPath, sourceLeaf, tokens[tokens.length - 1], 'after')
9682
+ else if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(targetIdPath, [sourceLeaf])
9683
+ }
9684
+ const oldContainer = folderContainerOf(source.idPath)
9685
+ if (oldContainer && typeof actions.setFolderOrder === 'function') {
9686
+ const owner = ownerPrefixOf(source.idPath)
9687
+ const newKey = owner === '' ? newSourcePath : owner + '//' + newSourcePath
9688
+ if (newKey !== source.idPath) actions.setFolderOrder(newKey, levelSeqOf(oldContainer).map(levelTokenOf))
9493
9689
  }
9494
9690
  })
9495
9691
  // (v0.36.4) NO automatic expand after a move-into. v0.36.3 opened
@@ -10085,14 +10281,28 @@ window.__ModuleLoader__.load({
10085
10281
  } else if (groupBy === 'flat') {
10086
10282
  bodyRows.push(...renderFlatRows())
10087
10283
  } else if (groupBy === 'workspace') {
10088
- // Official 'workspace' mode (v0.13): one level — every workspace at
10089
- // the root, no name groups, no disk nesting, no chain compression.
10090
- // Session rows keep the slash toggle; disk-nested children do not
10091
- // recurse here because raw items carry no .sub level.
10092
- // flatWorkspaceEntry, never the raw item: see its note — the raw one
10093
- // has no `leaf` and the row would render nameless.
10094
- for (const workspace of items || []) {
10095
- bodyRows.push(...renderWorkspaceEntry({ workspace: flatWorkspaceEntry(workspace) }, 0, wsPulseOf(workspace)))
10284
+ if (workspaceGroupMode === 'disk') {
10285
+ // Official 'workspace' mode (v0.13): one level — every workspace at
10286
+ // the root, no name groups, no disk nesting, no chain compression.
10287
+ // Session rows keep the slash toggle; disk-nested children do not
10288
+ // recurse here because raw items carry no .sub level.
10289
+ // flatWorkspaceEntry, never the raw item: see its note — the raw
10290
+ // one has no `leaf` and the row would render nameless.
10291
+ for (const workspace of items || []) {
10292
+ bodyRows.push(...renderWorkspaceEntry({ workspace: flatWorkspaceEntry(workspace) }, 0, wsPulseOf(workspace)))
10293
+ }
10294
+ } else {
10295
+ // v0.36.11: with a NAME layer enabled (disk-slash / slash), the
10296
+ // official scatter contradicts the user's mental model — the name
10297
+ // tree the sidebar has been rendering all along should stay a tree,
10298
+ // just lifted to the root (disk nesting OFF = 'slash' mode in the
10299
+ // tree memo above). Same render loop as the tree branch below:
10300
+ // mixed sequence, drag events, ungrouped block all apply.
10301
+ for (const item of levelSeqOf(tree)) {
10302
+ bodyRows.push(...(item.kind === 'ws'
10303
+ ? renderWorkspaceEntry({ workspace: item.entry }, 0, wsPulseOf(item.entry))
10304
+ : renderPlainFolder(item.node, 0)))
10305
+ }
10096
10306
  }
10097
10307
  bodyRows.push(...renderUngroupedBlock())
10098
10308
  } else {