dsh-session-navigator

树与放置

左侧那棵树不是 DSH 的树。DSH 只认识 workspace 和 session 两种东西;这棵树是它们加上 用户自己造的节点(文件夹、快捷方式)的一个投影,由 host 每次请求重建,只有用户造的部分落盘。

规则集中在 lib/domain/tree-rules.js,lib/client.js 里没有任何决策表——它只读节点上算好的 字段(accepts / scope / hasWorkspace)和两条形状(子树、父链)。三处各写一份规则是 bug 生成器,所以规则只有一份。

1. 节点种类

NODE_KINDS(lib/domain/tree-rules.js:36):

kind id 能装目录 能装会话 说明
root f-root ✅ ❌ 只装目录;徽章是活跃会话总数
all f-all ❌ 视图 所有活跃会话的平铺视图
ungrouped f-ungrouped ✅ ✅ 真容器:没有 workspace 的会话的家
todos f-todos ❌ 快捷方式 引用容器
favorites f-favorites ❌ 快捷方式 引用容器
trash f-trash ❌ 回收站 根节点之外唯一的分支
folder d-… ✅ ❌ 用户建的目录
workspace w-… ✅ ✅ DSH workspace 的投影
shortcut s-… ❌ 视 target 而定 引用,永不移动目标

roots 是 ["f-root", "f-trash"]——回收站故意不在根下面,它是另一个分支。

2. 五条承重规则

每条都在 test/tree.test.mjs 里有覆盖。

规则 内容 代码
R1 workspace 永不嵌进另一个 workspace;文件夹可以夹在根与 workspace 之间,但夹不到两个 workspace 之间 canMove() 用祖先链判断”里面”,因为经过文件夹仍然算嵌套
R2 会话的容器是它的 workspace 节点;没有 workspace 就是 f-ungrouped placementOfNode()
R3 会话只在它的绝对位置出现,也只在那里被列为一行 placementOfNode() 是唯一的家;sessionsOfNode() 画这一层,sessionsUnderNode() 数子树
R4 收藏/待办装的是快捷方式(引用)而不是会话;引用不改变总数,根目录自己也不装会话 counts[...] 的赋值
R5 父节点消失的目录降级到根,而不是让子树变孤儿;环永远构造不出来 folderParent()

R5 的降级是纯函数:folderParent() 只依赖 folders 表,与迭代顺序无关。 父节点缺失、父节点不是文件夹、父链成环,三种情况一律回落到 f-root。

降级只适用于文件夹和 workspace。引用不降级:f-root 装的是目录(canHoldShortcuts(f-root) 为假),所以把一条父节点已消失的快捷方式挂到根上,只会得到一行没有任何分支渲染、也没有任何手势 能删掉的行(lib/domain/tree.js:278-296)。这种情况直接不建这一行;容器消失时 host 会先把里面的 引用上提(删文件夹 → 父节点,父节点是根则进 f-收藏;删 workspace → f-收藏)。

3. 徽章数的是子树,列表画的是这一层

这两个问题不一样,于是有两个函数:

// 这一层的行:只算"家在它自己身上"的会话
const holder = node.kind === NODE_KINDS.shortcut ? node.ref ?? nodeId : nodeId;
return live.filter((session) => placementOfNode(session, tree) === holder);

// 徽章:它下面(含自己)一共有多少会话
return live.filter((session) => ids.has(placementOfNode(session, tree)));

buildTree() 的计数用后者,再加上子树里的引用行:

counts[id] = sessionsUnderNode(id, view, treeLike, { trashedIds: trashed }).length + shortcutRowsWithin(id);

会话只在它的绝对位置出现一次。 站在一个文件夹里,看到的是这个文件夹装的东西——子文件夹、 workspace、引用行——而不是把里面 workspace 的会话翻上来跟 workspace 并排:那样文件夹看起来像自己 持有这些会话,而且两层看起来一模一样(m03586/m03587)。会话在自己的 workspace 页里出现,那里的 面包屑和详情卡给出它的绝对路径;sessionPlacement 指向文件夹的会话显示在那个文件夹里。

数字仍然说”这里下面有多少会话”,像目录旁边的文件计数——f-root 一直是这样(列表是目录,数字是 活跃会话总数),文件夹只是把同一个问题往下一层问。所以数字可以大于这一层的行数,绝不会小于: 一个只装着 workspace 的文件夹写 4,那 4 条会话在那个 workspace 页里。四个例外各自有意义:

在这之前还有一道前置:看不见的会话不计数。buildTree() 先把 sessions 按 listSettings.hideBlank 过一遍,计数用的是”列表真正会画”的那个集合(lib/domain/tree.js:149):

const shown = listSettings?.hideBlank === false ? live : live.filter((session) => session.blank !== true);

过滤空会话是视图设置,不是删除——关掉它,数字立刻回来。之所以修在 host 而不是 client:徽章比行多, 就是 R2/R3 禁止的那种”两处各算一次”,只不过差了一步。

4. 会话放置(sessionPlacement)

核心矛盾:DSH 从 header 的 cwd 推导会话属于哪个 workspace。要改变这个归属,唯一物理手段 是搬目录——而”不重构 sessions/“是本插件的第一条硬约束。

于是有了这层覆盖:sessionPlacement[sessionId] = 节点 id,只影响分组,不动一个字节。

解析顺序(placementOfNode)
  sessionPlacement[id] 存在且节点还在  → 用它
  会话有 workspaceId                    → w-<workspaceId>
  会话没有 workspaceId                  → f-ungrouped
  有 workspaceId 但那个 workspace 没了   → null(只出现在"所有会话"里)

buildTree() 把结果一次性算进 sessionHome,随每个会话下发,客户端不重放这个判断(R2)。 homeOverride 标记”这是虚拟放置而不是 DSH 自己的归属”。

跨 workspace 拖会话因此有两条路:真的搬(POST /move,改文件)或只改分组 (POST /sessions/placement,改侧车)。前者是移动,后者是重命名一个视图。

5. 快捷方式

lib/domain/shortcuts.js。一条快捷方式是自己的节点,targetId 永远在目标自己的 id 空间里: 文件夹 d-…、workspace 去掉 w- 前缀、会话原样。

三条约束:

  1. 快捷方式加在真容器之后,所以它永远不会遮蔽一个真 id;
  2. 它不给父节点贡献会话数(R4)——同一个会话在它家出现一次,在每个引用处各出现一次;
  3. 引用不能活得比目标久。指向会话的引用在会话进回收站时保留(会话还能回来), 一旦 payload 真的没了(清空回收站,或用户手工删了目录),这条引用就消失—— 否则用户会得到一行点了没反应、又不允许删的行。host 在 purge 时顺手清侧车, 保证下次重建不会把它带回来。同样地,指向文件夹/workspace 的引用在目标不存在时也不建行 (lib/domain/tree.js:289-291);而引用的容器不存在时也不建行,见 §2 的 R5 说明。

标签不在快捷方式上:name 留空,客户端从目标解析,所以目标改名后引用自动跟上。

5.1 收藏 / 待办 里的成员显示成它引用的东西

收藏或待办里一条指向文件夹 / workspace 的成员,在页面上画成那个容器本身:目标的图标与名字、 工作区 / 文件夹 标签,行 id 仍然是快捷方式的 s-…。所以移出、清空只删成员关系,永远不动原容器; 双击进它的原位置,右键只有 跳转到原位置 / 移出。

指向会话的成员不再多画一行:会话自己的 favorite / todo 就是这条成员关系——lib/sources/reader.js 用 shortcutTargetsIn(...) 推导,注解里的旧标记由 lib/host/flags.js 在启动时迁移成快捷方式(写树在前、 清标记在后,写失败就留着旧标记)。于是同一个会话可以同时在收藏和待办里,而回收站里那一份文件一个字节都没变。

转到会话原位置 只在这两个页面里、且只在会话行上出现:它把列表切到会话的 home 再选中它。 在本目录生成快捷方式 是原来那个「发送到」子菜单的替代,只做一件事——把当前这条会话 / 目录放进当前所在的 容器。收藏 / 待办里没有这一项(那里的行是成员关系,不是容器),根目录与所有会话也没有(它们不装引用), 放不下的位置会直接说清楚,而不是悄悄回落到收藏。

6. 置顶

DSH 置顶的是会话;这里还有容器置顶(文件夹、workspace),存在树的侧车里 (pins[id] = 时间戳),按时间倒序。两块合起来是视觉上的同一条置顶带,但它们来自两套存储, 所以 /bootstrap 里 pinnedSessionIds 和 tree.pinnedNodes 是分开的两个字段。

树里的已置顶行在名字后面带一个小图钉(.tpin,10px,accent 色):行留在原地、只是排到兄弟最前, 别的什么都不变,所以图钉就是全部的可见差别。列表行本来就有同样的标记,两边从此一致。

7. 移动的合法目标

acceptsOf(nodeId, tree) 返回四个布尔,客户端直接照它涂色:

字段 含义
accepts.folder 目录(文件夹/workspace)能不能落在这里
accepts.workspace 带着 workspace 的目录能不能落在这里(R1 从目标侧问)
accepts.session 会话能不能放在这里(只有 workspace 和 f-ungrouped)
accepts.shortcut 引用能不能丢进来(canHoldShortcuts)

canMove() 只用两条规则:不能进自己的子树(会整支掉出树)、workspace 不能套 workspace。 其余组合全部自由:文件夹进文件夹、文件夹进 workspace、workspace 进文件夹、workspace 在根下。

两条规则问的都是结构(subtreeOf(id, tree, { followReferences: false })):引用不是父边, 所以”装了一条指向 workspace W 的引用的文件夹”不算”带着 W”,把这条引用拖进 W 也不是环。 列表那一侧仍然跟随引用(默认 followReferences: true),因为引用的意义就是显示目标的行。

固定节点不可移动;客户端会先把非法目标变灰,但 host 一定会再校验一次——一个过期的客户端 不应该能写出非法的树。

7.1 写进 draft 之前的第二次校验

canMove() 看的是请求到达时读到的树。两个移动同时到达时,它们看到的是同一棵”之前”的树: A 进 B 通过,B 进 A 也通过,写下去就是一个环(渲染时整支降级到根)。所以 moveNode() (lib/domain/folders.js)在 mutate 回调里再问一次 isInsideDraft(draft, nodeId, parentId), 看到的是”第二次移动即将扩展的那份 draft”,于是第二次被拒(cannot move … inside itself)。 sidecar.mutate 的串行锁让这次检查是原子的。

7.2 名字与顺序

同一父节点下不允许重名(assertUniqueName,比较前 trim + 小写;不同父节点可以同名), 新建、重命名、移动三条路径都会查。order 也不再取 siblings.length——删掉中间一个之后, length 会把最后一个兄弟的编号再发一次,两行同号,用户排好的次序变成平局。 现在取”当前最大 order + 1”(nextOrder),编号单调。

8. 快速访问(书签带)

tree.quickAccess(规则在 lib/domain/quick-access.js)是「目录」上面那条按钮带:{ 节点 id: 序号 }, 投影按序号从新到旧排进 quickAccessNodes。它和置顶是两件事,这是刻意的(m03020):

  存哪 是什么 行为
置顶 tree.pins 在本来的带子里排名 行留在原地,排到兄弟最前
快速访问 tree.quickAccess 同一容器的第二个入口 目录上面多一个按钮

共用一张表时「置顶一个文件夹」会把它从 目录 里挪走,两个功能看起来是同一个功能写了两遍。

9. 多选与批量动作

勾选是「多行上的同一个动作」,不是每行一个小事务。菜单里每一项都对整批做同一件事,方向只读一次 (第一个被勾选的项,或者「是不是全都在里面」),所以混合选择不会被翻转一半:

项 写什么 什么时候出现
收藏 / 待办 {n} 项 POST /shortcuts 或 DELETE /shortcuts/:id 有勾选就有
置顶 / 取消置顶 {n} 项 POST /sessions/:id/pin、POST /nodes/:id/pin 有勾选就有
归档 / 取消归档 {n} 项 POST /sessions/:id/archive 勾选的全是会话
添加到 / 移出快速访问 {n} 项 POST /nodes/:id/quick-access 勾选的全是容器

两条「全部」规则互为镜像:归档需要每一行都是会话(容器没有归档),快速访问需要每一行都是容器(会话不是 地方)。勾选行先用 bulkTargetOf 解析:收藏 / 待办里的成员行作用于它背后的会话或容器,不会把一个快捷方式 再套进另一个快捷方式。

框选是勾选的快捷方式,不是第二种选择。在列表空白处按下并拖动,途中的行一起进集合(与已有勾选取并集 还是替换,看有没有按住 ctrl / ⌘:按住是并集);松手留下结果。

行本身用的是原生 HTML5 拖放,所以拖动先要选中(rowDraggable()):当前行(点过的那行)或已勾选的行拖走 自己,没被选中的行和空白一样是可以起框的表面——否则浏览器会先拉起原生拖影,框永远收不到那次按下。这条闸口 只写一遍,行用它决定 draggable,框用它决定要不要让路(marqueeDrag 的 canDragRow);两边一旦不同源, 就会出现「拖出幻影但什么都没动」。按在行内控件上(复选框、按钮、列宽手柄、折叠箭头)依旧不起框。

双击打开有自己的时限(countsAsDoublePress(),DOUBLE_CLICK_MS = 400):浏览器给的 dblclick 阈值来自系统设置,慢设置下两次悠哉的按下也算一次双击,而打开会话意味着启动 DSH——代价太大,所以打开被挪到 onClick 的第二次按下上,凭同一行、400ms 内这一对来认;慢一拍只算两次单击,而且那次慢按成为新一对的开始(连点三下是「一次双击 + 一次单击」,不是两次双击)。onDoubleClick 只留 preventDefault + stopPropagation,用来吞掉选中文字。

拖的是整份勾选集,起手那行只是其中一员(m01540)。勾选集里会话和容器可以混着,而 id 前缀判不出 (工作区是 w-、文件夹是 f-,会话 id 不透明),所以起手时用 splitDragIds() 按树和索引分成两个 半边,落点各取自己收得下的那半:文件夹行只收容器(draggedNodeIds()),会话槽只收会话 (draggedSessionIds())。往文件夹里放多个容器走 moveNodesTo()——一次 mutate、一条提示 (已移动 {0} 个目录到「{1}」),部分放不下的先提示再搬放得下的;只勾一个时它退回 moveNodeTo(), 保留原来那句单数提示。浏览器读不到 DataTransfer 时回落到模块里的 dragPayload,因为拖动载荷本来就是 同源信息,不必依赖那个可能被隐私设置清空的槽。

判定只看矩形与行的重叠,所以擦过一个角也算命中,而仅仅上下相邻不算——零面积的矩形(也就是一次点击) 什么都不选,空白处(或没选中的行上)点一下因此是「取消全选」:setChecked(new Set()) 并清掉当前行。框本身 是一个真实的 .selrect 元素,拖动时直接改它的 left/top/width/height 而不重渲染列表,贴近上下边缘时列表跟着滚。

起始点是内容上的一个点,不是屏幕上的一个点:按下时记下列表当时的 scrollTop/scrollLeft,之后每帧把滚动增量抵消掉(m01775)。否则滚轮一动,行在静止的指针下滑走,而用屏幕坐标算的起点不动,整个选区跟着手指往下漂——「起始位置没有锁定」。

框是拿面板当前矩形画的,每帧重读一次而不是按下时取一份快照:手势期间布局会动(滚轮、滚动条、贴边 自动滚、面板换尺寸、祖先位移),快照一陈旧,画的框和命中的行就一起偏移(m01540)。同理,列表 scroll 会点亮脏标记——没有 mousemove 时行也会在静止的指针下走过去,不重算就会”框到上一帧的集合”。

方向是「全都已经在里面了才移出」,否则把缺的加进去(flagMany / pinMany / quickMany),文案跟着翻成 取消收藏 {n} 项 这类。归档会把 DSH 那边的置顶清掉——那是 DSH 自己的规则(「DSH refuses to pin an archived session」,lib/index.js 有注释),不是这里的批量逻辑。

10. 相关文件

文件 职责
lib/domain/tree.js buildTree():造节点、算 sessionHome、算徽章、排序
lib/domain/tree-rules.js 全部规则:placementOfNode / sessionsOfNode / sessionsUnderNode / acceptsOf / canMove / decorateTree
lib/domain/shortcuts.js 快捷方式的规范化、目标解析、canHoldShortcuts、dropShortcutsTo
lib/domain/quick-access.js 快速访问:isBookmarkable 谁能当书签、applyQuickAccessOrder 整条写
lib/host/flags.js 一次性迁移:注解里的收藏 / 待办标记 → 快捷方式
lib/domain/folders.js 文件夹命名、新建、上提删除
lib/domain/sessions.js 合并视图、home / homeOverride 的推导