左侧那棵树不是 DSH 的树。DSH 只认识 workspace 和 session 两种东西;这棵树是它们加上 用户自己造的节点(文件夹、快捷方式)的一个投影,由 host 每次请求重建,只有用户造的部分落盘。
规则集中在 lib/domain/tree-rules.js,lib/client.js 里没有任何决策表——它只读节点上算好的
字段(accepts / scope / hasWorkspace)和两条形状(子树、父链)。三处各写一份规则是 bug
生成器,所以规则只有一份。
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"]——回收站故意不在根下面,它是另一个分支。
每条都在 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-收藏)。
这两个问题不一样,于是有两个函数:
// 这一层的行:只算"家在它自己身上"的会话
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 页里。四个例外各自有意义:
f-root 的列表是目录(空),但数字是活跃会话总数——那正是它旁边那个数的意思;f-favorites / f-todos = 带标记的会话数 + 指向会话的快捷方式行数;f-trash = 回收站条目数;在这之前还有一道前置:看不见的会话不计数。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 禁止的那种”两处各算一次”,只不过差了一步。
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,改侧车)。前者是移动,后者是重命名一个视图。
lib/domain/shortcuts.js。一条快捷方式是自己的节点,targetId 永远在目标自己的 id 空间里:
文件夹 d-…、workspace 去掉 w- 前缀、会话原样。
三条约束:
lib/domain/tree.js:289-291);而引用的容器不存在时也不建行,见 §2 的 R5 说明。标签不在快捷方式上:name 留空,客户端从目标解析,所以目标改名后引用自动跟上。
收藏或待办里一条指向文件夹 / workspace 的成员,在页面上画成那个容器本身:目标的图标与名字、
工作区 / 文件夹 标签,行 id 仍然是快捷方式的 s-…。所以移出、清空只删成员关系,永远不动原容器;
双击进它的原位置,右键只有 跳转到原位置 / 移出。
指向会话的成员不再多画一行:会话自己的 favorite / todo 就是这条成员关系——lib/sources/reader.js
用 shortcutTargetsIn(...) 推导,注解里的旧标记由 lib/host/flags.js 在启动时迁移成快捷方式(写树在前、
清标记在后,写失败就留着旧标记)。于是同一个会话可以同时在收藏和待办里,而回收站里那一份文件一个字节都没变。
转到会话原位置 只在这两个页面里、且只在会话行上出现:它把列表切到会话的 home 再选中它。
在本目录生成快捷方式 是原来那个「发送到」子菜单的替代,只做一件事——把当前这条会话 / 目录放进当前所在的
容器。收藏 / 待办里没有这一项(那里的行是成员关系,不是容器),根目录与所有会话也没有(它们不装引用),
放不下的位置会直接说清楚,而不是悄悄回落到收藏。
DSH 置顶的是会话;这里还有容器置顶(文件夹、workspace),存在树的侧车里
(pins[id] = 时间戳),按时间倒序。两块合起来是视觉上的同一条置顶带,但它们来自两套存储,
所以 /bootstrap 里 pinnedSessionIds 和 tree.pinnedNodes 是分开的两个字段。
树里的已置顶行在名字后面带一个小图钉(.tpin,10px,accent 色):行留在原地、只是排到兄弟最前,
别的什么都不变,所以图钉就是全部的可见差别。列表行本来就有同样的标记,两边从此一致。
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 一定会再校验一次——一个过期的客户端 不应该能写出非法的树。
canMove() 看的是请求到达时读到的树。两个移动同时到达时,它们看到的是同一棵”之前”的树:
A 进 B 通过,B 进 A 也通过,写下去就是一个环(渲染时整支降级到根)。所以 moveNode()
(lib/domain/folders.js)在 mutate 回调里再问一次 isInsideDraft(draft, nodeId, parentId),
看到的是”第二次移动即将扩展的那份 draft”,于是第二次被拒(cannot move … inside itself)。
sidecar.mutate 的串行锁让这次检查是原子的。
同一父节点下不允许重名(assertUniqueName,比较前 trim + 小写;不同父节点可以同名),
新建、重命名、移动三条路径都会查。order 也不再取 siblings.length——删掉中间一个之后,
length 会把最后一个兄弟的编号再发一次,两行同号,用户排好的次序变成平局。
现在取”当前最大 order + 1”(nextOrder),编号单调。
tree.quickAccess(规则在 lib/domain/quick-access.js)是「目录」上面那条按钮带:{ 节点 id: 序号 },
投影按序号从新到旧排进 quickAccessNodes。它和置顶是两件事,这是刻意的(m03020):
| 存哪 | 是什么 | 行为 | |
|---|---|---|---|
| 置顶 | tree.pins |
在本来的带子里排名 | 行留在原地,排到兄弟最前 |
| 快速访问 | tree.quickAccess |
同一容器的第二个入口 | 目录上面多一个按钮 |
共用一张表时「置顶一个文件夹」会把它从 目录 里挪走,两个功能看起来是同一个功能写了两遍。
isBookmarkable):文件夹还在的 d-…,或 w-…。视图(根目录 / 所有会话 /
回收站)、收藏 / 待办、快捷方式都不是「地方」,在左栏已经是一行。POST /quick-access 一次写整条(applyQuickAccessOrder)。
往带上拖一个文件夹(加)和把一个书签拖到另一个上(重排)因此是同一句话——「现在就是这个顺序」。
写的时候陌生 id 被跳过而不是拒绝(手势来自一棵活的树),已经在带里却没出现在列表里的书签保留原序号,
排到新写的下面:一次拖动不会顺手扔掉用户没碰过的书签。containerDrop),不是加书签(m03793)。deleteFolder / deleteWorkspace 顺手删掉 pins 与 quickAccess 里的条目,
两张表都不会只增不减。勾选是「多行上的同一个动作」,不是每行一个小事务。菜单里每一项都对整批做同一件事,方向只读一次 (第一个被勾选的项,或者「是不是全都在里面」),所以混合选择不会被翻转一半:
| 项 | 写什么 | 什么时候出现 |
|---|---|---|
收藏 / 待办 {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 有注释),不是这里的批量逻辑。
| 文件 | 职责 |
|---|---|
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 的推导 |