align workbench open editors tabs

This commit is contained in:
lix-2026
2026-05-21 05:57:19 +08:00
parent eba1010191
commit fd94792ea6
12 changed files with 897 additions and 2 deletions
@@ -0,0 +1,339 @@
# Proposal: MNote Main Tab 键盘导航与 MRU 行为
> 日期:2026-05-21
>
> 状态:proposed
>
> 来源:[Reasonix Worker B](../../.codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md)
>
> 参考:Sidex `MultiEditorTabsControl`、MNote `activateMainEditorTab` / `bindMainEditorPageTab` / `resourceTabMru`
---
## 1. 目标
在现有主编辑区 tab strip 上增加轻量键盘导航和 MRU 回退行为,不引入完整 VSCode tab control 体系。
| 行为 | Sidex 做法 | MNote 对应 |
|------|-----------|-----------|
| 左右箭头 roving focus | `KEY_UP``ArrowLeft/Right` 计算相邻 editorIndex | tab strip `keydown` → 循环 `resourceTabRegistry` + page tab |
| Enter/Space 激活 | `KEY_UP``groupView.openEditor(editor)` | `activateMainEditorTab(objectIdentity)` |
| Home/End 跳转 | 计算 `editorIndex=0``count-1` | 跳转到 page tab (Home) 或最后一个 resource tab (End) |
| 关闭后 MRU 回退 | VSCode 的 `MRU` 排序 | 已有 `lastActiveResourceTabKey()`,需确认行为正确 |
| 关闭 active tab 后 focus 迁移 | 激活 MRU 中下一个 tab | `closeResourceTab` 最后调用 `activateMainEditorTab(lastActiveResourceTabKey())` |
---
## 2. 需要修改的源码函数
所有修改均位于 `rust/crates/mnote-web/src/routes/web_shell.rs`
### 2.1 新增函数:`bindMainEditorTabStrip()`
**位置**:紧接 `bindMainEditorPageTab`(约 line 3450)。
```javascript
const bindMainEditorTabStrip = () => {
const nodes = resourceTabHostNodes();
if (!(nodes.strip instanceof HTMLElement)) return;
if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
// Helper: collect all tab buttons in DOM order (page tab first, then resource tabs)
const collectTabs = () => {
const tabs = [];
if (nodes.pageTab instanceof HTMLElement && nodes.pageTab.isConnected) tabs.push(nodes.pageTab);
resourceTabRegistry.forEach((entry) => {
if (entry.tab instanceof HTMLElement && entry.tab.isConnected) tabs.push(entry.tab);
});
return tabs;
};
// Helper: find currently focused tab index
const findFocusedIndex = (tabs) => {
const active = document.activeElement;
return tabs.findIndex((tab) => tab === active);
};
nodes.strip.addEventListener('keydown', (event) => {
const tabs = collectTabs();
if (tabs.length === 0) return;
const focusedIndex = findFocusedIndex(tabs);
let targetIndex = -1;
switch (event.key) {
case 'ArrowRight': {
event.preventDefault();
targetIndex = focusedIndex >= 0
? (focusedIndex + 1) % tabs.length
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
if (targetIndex < 0) targetIndex = 0;
break;
}
case 'ArrowLeft': {
event.preventDefault();
targetIndex = focusedIndex >= 0
? (focusedIndex - 1 + tabs.length) % tabs.length
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
if (targetIndex < 0) targetIndex = 0;
break;
}
case 'Home': {
event.preventDefault();
targetIndex = 0;
break;
}
case 'End': {
event.preventDefault();
targetIndex = tabs.length - 1;
break;
}
case 'Enter':
case ' ': { // Space
event.preventDefault();
const activeTab = tabs[focusedIndex >= 0 ? focusedIndex
: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true')];
if (activeTab) activeTab.click();
return;
}
default:
return; // don't re-focus
}
if (targetIndex >= 0 && targetIndex < tabs.length) {
tabs[targetIndex].focus();
}
});
};
```
**设计理由**
- 使用 `keydown` 而非 `keyup`Sidex 用 `KEY_UP` 是为了与 `StandardKeyboardEvent` 兼容,但 MNote 无此抽象):`keydown` 捕获更快、防止浏览器默认滚动。
- `collectTabs()` 每次实时查询 DOM 顺序,避免缓存过期。
- `Enter`/`Space` 复用已有 `click` 事件处理(`createResourceTabDom` 中已有 click handler)。
- 只处理 `strip` 上的事件,利用事件冒泡获取已在 DOM 内的所有 tab。
### 2.2 修改 `bindMainEditorPageTab()`(约 line 3450
当前函数只绑定 page tab 的 `click`。需在末尾追加:
```javascript
// 同时也绑定 tab strip 键盘导航
bindMainEditorTabStrip();
```
或在 `bindMainEditorPageTab` 返回前调用一次。
### 2.3 修改 `createResourceTabDom()`(约 line 3507
新创建的 resource tab 已有 `tabindex="-1"` 初始值。**无需额外修改**,因为 `activateMainEditorTab` 已正确设置 active tab 的 `tabindex="0"`,其他 tab `tabindex="-1"`
但需要确认:`activateMainEditorTab` 遍历 `resourceTabRegistry` 时,**page tab 和 resource tab 的 `tabindex` 一致性**。当前实现:
```javascript
// activateMainEditorTab 中(line 3428-3442
if (nodes.pageTab instanceof HTMLElement) {
nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
}
// resource tabs:
entry.tab.setAttribute('tabindex', active ? '0' : '-1');
```
→ 已满足 roving tabindex 的前置条件。**无需修改**。
### 2.4 修改 `closeResourceTab()`(约 line 3461
当前关闭后通过 `activateMainEditorTab(lastActiveResourceTabKey())` 回退。但 focus 未迁移到新激活的 tab。需在 `activateMainEditorTab` 调用后**追加 focus**
```javascript
// closeResourceTab 末尾,将 focus 移到新激活的 tab
const nextKey = lastActiveResourceTabKey();
activateMainEditorTab(nextKey);
// 新激活 tab 获得 focus
const nextTab = nextKey
? resourceTabRegistry.get(nextKey)?.tab
: nodes.pageTab;
if (nextTab instanceof HTMLElement) nextTab.focus();
```
### 2.5 确认 MRU 行为(`touchResourceTabMru` + `lastActiveResourceTabKey`
现有 MRU 实现已足够:
```javascript
// touchResourceTabMru (line 3289-3295)
const touchResourceTabMru = (key) => {
const id = String(key || '').trim();
if (!id) return;
const index = resourceTabMru.indexOf(id);
if (index >= 0) resourceTabMru.splice(index, 1);
resourceTabMru.unshift(id);
if (resourceTabMru.length > resourceTabMruMax) resourceTabMru.length = resourceTabMruMax;
};
// lastActiveResourceTabKey (line 3309-3313)
const lastActiveResourceTabKey = () => {
for (const key of resourceTabMru) {
if (resourceTabRegistry.has(key)) return key;
}
return '';
};
```
**行为验证**
- 激活一个 tab → `touchResourceTabMru(key)` 将其移到队列头部
- 关闭当前 tab → `removeFromResourceTabMru(key)` 移除 → `lastActiveResourceTabKey()` 返回 MRU 中第一个仍存在 registry 的 key
- 如果没有任何 resource tab 存活 → 返回 `''``activateMainEditorTab('')` 激活 page tab
**可用,无需修改 MRU 核心逻辑**
---
## 3. 需新增的 CSS`styles.rs`
### 3.1 Focus 可见样式
当前 `mnote-main-tab` 没有 `:focus-visible` 样式。添加:
```css
.mnote-main-tab:focus-visible {
outline: 2px solid var(--atelier-primary, #006E28);
outline-offset: -2px;
}
```
### 3.2 可选:resource tab DOM 顺序保证
如果 resource tab 需要在 DOM 中保持 MRU 可见顺序(而非插入顺序),则 `createResourceTabDom``nodes.strip.append(tab)` 需改为按 MRU 顺序插入。但**当前不要求**——tabs 按打开顺序排列,MRU 只控制回退。
---
## 4. 需要更新的 contract test
文件:`rust/crates/mnote-web/src/routes/web_shell.rs` 末尾的 Rust 单元测试(约 line 4528+)。
### 4.1 新增 assert
```rust
assert!(html.contains("data-mnote-tab-strip-bound"));
assert!(html.contains("bindMainEditorTabStrip"));
```
即检查生成的 JS 中包含 `bindMainEditorTabStrip` 函数的定义字符串和 `data-mnote-tab-strip-bound` guard attribute。
### 4.2 已有断言确认
以下已有断言确保关键 tab DOM 结构存在:
```rust
assert!(html.contains("data-mnote-main-tab-strip")); // line 4538
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title")); // line 4541
```
---
## 5. 需要新增的 smoke test
新建 `scripts/task475-editor-tab-keyboard-mru-smoke.js`(参考 `task457` 的 Playwright harness 写法)。
### 5.1 测试断言
| # | 断言 | 方法 |
|---|------|------|
| 1 | tab strip 可接收 keyboard focus | 在 strip 上 `page.keyboard.press('ArrowRight')` 后检查 `document.activeElement` 属于 `[data-mnote-main-tab]` |
| 2 | ArrowRight 循环聚焦 | focus page tab → ArrowRight → focused tab 应为第一个 resource tab |
| 3 | ArrowLeft 反向循环 | focus 第二个 resource tab → ArrowLeft → focused tab 应为第一个 resource tab |
| 4 | Home 跳到第一个 tab | focus 任意 resource tab → Home → focused 应为 page tab |
| 5 | End 跳到最后一个 tab | focus page tab → End → focused 应为最后一个 resource tab |
| 6 | Enter 激活 focused tab | ArrowRight 到 resource tab → Enter → 该 tab 应 `aria-selected="true"` |
| 7 | Space 激活 focused tab | 同上,使用 Space |
| 8 | 关闭 active tab 后 focus 迁移 | 打开 2 个 resource tab,激活第二个 → close → focus 应回到 MRU 中的第一个 |
| 9 | 关闭最后一个 resource tab 后回退 page tab | 打开 1 个 resource tab → close → page tab 应激活且 focused |
| 10 | `resourceTabMru` 顺序正确 | 打开 tab A、B、C,激活 B → 检查 MRU 数组头部是 B |
### 5.2 辅助函数
```javascript
// 获取当前 focused tab 的 identity
const getActiveTabId = () => page.evaluate(() => {
const active = document.activeElement;
return active?.getAttribute('data-mnote-main-tab') || null;
});
// 检查 aria-selected
const getSelectedTabId = () => page.evaluate(() => {
const selected = document.querySelector('[data-mnote-main-tab][aria-selected="true"]');
return selected?.getAttribute('data-mnote-main-tab') || null;
});
// 获取 MRU 队列
const getMruQueue = () => page.evaluate(() => {
// 通过 window 对象暴露(需在 web_shell.rs 中给 window.__mnoteResourceTabMru 赋值)
return window.__mnoteResourceTabMru || [];
});
```
---
## 6. DOM 状态总结
执行后,main tab 的 DOM 状态契约:
| Attribute / Property | 用途 | 取值 |
|----------------------|------|------|
| `data-mnote-main-tab-strip` | tab strip 根元素 | `"true"` |
| `data-mnote-tab-strip-bound` | 防止重复绑定 keyboard handler | `"true"`JS 运行时设置) |
| `aria-selected="true"` | 当前激活 tab | 仅一个 tab 有 |
| `tabindex="0"` | 当前可聚焦 tabroving | 与 `aria-selected` 同步 |
| `tabindex="-1"` | 不可直接聚焦 tab | 其他所有 tab |
| `data-mnote-main-tab="page"` | page tab 标识 | SSR 初始 |
| `data-mnote-main-tab="<identity>"` | resource tab 标识 | JS 创建 |
| `class="mnote-main-tab"` | 所有 tab 共享 class | 全部 |
| `class="is-active"` | 当前激活 tab | 与 `aria-selected` 同步 |
| `role="tab"` | ARIA tab role | 全部 |
---
## 7. 风险与边界情况
| 风险 | 影响 | 缓解 |
|------|------|------|
| keyboard 事件与编辑器快捷键冲突 | ArrowLeft/Right 被编辑区拦截 | 事件只在 `focus` 位于 tab strip 内时触发(`document.activeElement` 检查) |
| `resourceTabRegistry` 遍历顺序不确定 | `collectTabs()` 中 tab 顺序可能乱序 | `collectTabs()` 顺序:page tab 固定首位 + `resourceTabRegistry.forEach`V8 中 `Map.forEach` 按插入顺序) |
| 关闭 tab 时 DOM 元素刚被 remove | `isConnected` 检查失败 | `closeResourceTab` 需要先查 nextTab,再删当前 tab 或重新查询 |
| 多个 resource tab 同时关闭 | MRU 队列清空后无回退 | `lastActiveResourceTabKey()` 返回 `''` 已正确处理 |
| 与 Worker A 的并行修改冲突 | `web_shell.rs` 中函数顺序和行号漂移 | 本 proposal 以函数名定位,不依赖行号 |
---
## 8. 实现顺序(最小安全步骤)
```
Step 1: 在 bindMainEditorPageTab 后新增 bindMainEditorTabStrip 函数
实现 keydown 事件委托 + collectTabs + 箭头/Home/End/Enter/Space
Step 2: 修改 closeResourceTab 末尾
在 activateMainEditorTab 后将 focus 移到新激活 tab
Step 3: 在 styles.rs 中补 :focus-visible 样式
Step 4: 更新 Rust contract test(新增 bindMainEditorTabStrip / data-mnote-tab-strip-bound 断言)
Step 5: 新建 scripts/task475-editor-tab-keyboard-mru-smoke.js
实现 10 个测试断言
Step 6: 验收
cargo test -p mnote-web web_shell -- --test-threads=1
node scripts/task475-editor-tab-keyboard-mru-smoke.js
```
---
## 9. 非目标(明确不做的)
- 不实现 tab 拖拽排序
- 不实现 `Ctrl+Tab` / `Ctrl+Shift+Tab` 的 MRU 循环(那是全局快捷键,不是 tab strip keyboard 行为)
- 不实现 close others / close right 菜单
- 不改变 page/resource tab identity 机制
- 不添加 tab overflow 滚动
@@ -0,0 +1,41 @@
# Reasonix Worker ASidex Open Editors 轻量视图
Project root: `/mnt/Data1T/mnote`
## 目标
参考 `reference-code/sidex-main` 的 VSCode `OpenEditorsView`,实现 MNote 轻量 open editors snapshot。优先让 page tab + resource tabs 的状态可被统一读取和测试;只有在非常小范围可控时才新增轻量 UI。
## Ownership
你可以修改:
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- 可新增 `scripts/task47*-open-editors-*.js`
- `design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md`
不要修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- ACP / Hermes 相关文件
## 参考
- Sidex`OpenEditorsView``IEditorGroupsService``GroupModelChangeKind`
- MNote`resourceTabRegistry``activateMainEditorTab``createResourceTabDom``closeResourceTab`
## 验收
- `cargo test -p mnote-web web_shell -- --test-threads=1`
- 如新增 smoke,运行对应 `node --check``node` 命令
## 输出要求
最终回复列出:
- 修改文件
- 实现的 snapshot/UI 合同
- 验证命令结果
- 未完成项和风险
@@ -0,0 +1,43 @@
# Reasonix Worker BSidex Tab 键盘与 MRU 对照实现
Project root: `/mnt/Data1T/mnote`
## 目标
参考 Sidex / VSCode `MultiEditorTabsControl`,为 MNote main tab 增加最小键盘/MRU 行为:左右箭头切换 focus、`Enter`/`Space` 激活、关闭 active resource tab 后按 MRU 或 page tab 回退。
## Ownership
由于 Worker A 也可能修改 `web_shell.rs`,本 worker **不要直接修改源码**。请输出具体 patch 建议到:
- `.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md`
你可以修改:
- `.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md`
- `design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md`
不要修改:
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- 任何业务源码
## 参考
- Sidex`MultiEditorTabsControl`
- MNote`bindMainEditorPageTab``activateMainEditorTab``resourceTabMru`
## 验收
只需文档级验收:
- proposal 明确列出要改的函数、事件、DOM 状态和测试断言。
## 输出要求
最终回复列出:
- 生成的 proposal 文件
- 建议修改点
- 认为最小安全实现的步骤
@@ -0,0 +1,42 @@
# Reasonix Worker C:资源打开 resolver 收敛
Project root: `/mnt/Data1T/mnote`
## 目标
继续对照 Sidex editor input resolver,收敛 MNote 正文附件、FileTree asset、new-window、active-tab 的资源打开策略。重点检查并尽量减少 `layout.rs` 中正文附件与 FileTree 的重复 Office / local file 分支。
## Ownership
你可以修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `scripts/task463-onlyoffice-resolver-smoke.js`
- `design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md`
不要修改:
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- ACP / Hermes 相关文件
## 约束
- 文件树 edit-mode active tab 行为已经可用,不能破坏。
- 正文附件 `弹窗编辑` 应继续进入 main resource tab。
- 正文附件 `新窗口编辑` 应继续打开浏览器新窗口。
- md/text/code 仍应走 tiptap / 内建编辑器,不要改成 OnlyOffice。
## 验收
- `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1`
- `node scripts/task463-onlyoffice-resolver-smoke.js`
## 输出要求
最终回复列出:
- 修改文件
- 收敛了哪些重复分支
- 验证命令结果
- 未完成项和风险
@@ -0,0 +1,38 @@
# Reasonix Worker DFileTree / Open Editors 生命周期 smoke 收口
Project root: `/mnt/Data1T/mnote`
## 目标
参考 Sidex Explorer + Open Editors 联动,收口 MNote filetree action smoke 和 checklist 状态。重点跑通或明确阻塞 `task430` / `task471`,并补充 action/open editor state 的断言建议。
## Ownership
你可以修改:
- `scripts/task430-vscode-explorer-stage7-smoke.js`
- `scripts/task471-local-folder-bulk-resource-trash-smoke.js`
- `design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md`
- `design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md`
不要修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 任何 Rust 源码
## 验收
- `node --check scripts/task430-vscode-explorer-stage7-smoke.js`
- `node --check scripts/task471-local-folder-bulk-resource-trash-smoke.js`
- `node scripts/task471-local-folder-bulk-resource-trash-smoke.js`
## 输出要求
最终回复列出:
- 修改文件
- 实际跑过的 smoke
- 哪些失败是环境前置,哪些是真 bug
- 对 4-44 / 4-45 checklist 的更新
@@ -0,0 +1,49 @@
# 4-45 FileTree / Open Editors Lifecycle Link Checklist v1
> 状态:process
>
> 日期:2026-05-21
>
> Ownerfiletree action smoke / resource lifecycle observability
## 1. 目标
参考 Sidex Explorer + Open Editors 联动,补 MNote filetree action 与 open editor state 的可观测测试。当前优先修 smoke 缺口和文档状态,不直接改核心业务。
## 2. 允许修改范围
- `scripts/task430-vscode-explorer-stage7-smoke.js`
- `scripts/task471-local-folder-bulk-resource-trash-smoke.js`
- `design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md`
- 如发现 checklist 已全部完成,可建议移动到 `done/`,但不要直接移动未验证文件。
## 3. 禁止事项
- 不修改 `layout.rs` / `web_shell.rs` / `local_folder_source.rs`
- 不改变 delete 默认到垃圾箱的语义。
- 不把环境准备失败伪装成通过。
## 4. Checklist
- [ ] 跑或修复 `task430` / `task471` 的可复现前置条件。
- [ ] 明确哪些 smoke 已过、哪些因环境阻塞。
- [ ] 补充 open editor / filetree active row / lifecycle 状态断言建议。
- [ ] 更新 4-44 checklist 执行记录。
## 5. 验收
- `node --check scripts/task430-vscode-explorer-stage7-smoke.js`
- `node --check scripts/task471-local-folder-bulk-resource-trash-smoke.js`
- `node scripts/task471-local-folder-bulk-resource-trash-smoke.js`
## 6. 非目标
- 不实现完整 undo stack。
- 不改 cloud storage drop executor。
## 7. 本轮执行记录
- 2026-05-21Reasonix Worker D 识别到 `task430` 的前置问题:新注册用户在 `workspaces:fetchWorkspaceSummaries` 前可能还没有 workspace,需要先通过 `/api/tree/commands` create action 触发 workspace bootstrap。
- 2026-05-21Worker D 还建议将 `task471` 的验证记录同步到 `4-44`,并补 open editor / lifecycle 状态断言。
- 2026-05-21Worker D 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 smoke 或 checklist;本轮不把 4-45 checklist 标记为完成。
- 后续建议:先修 `task430` workspace bootstrap,再新增 filetree active row 与 open editors snapshot 的联合断言,避免只验证树动作、不验证打开编辑器状态。
@@ -0,0 +1,51 @@
# 5-24 Open Editors Lightweight View Checklist v1
> 状态:process
>
> 日期:2026-05-21
>
> Ownermain editor tab / sidebar open editors parity
## 1. 目标
参考 Sidex / VSCode `OpenEditorsView`,为 MNote 做轻量版“打开的编辑器”视图或可观测模型。当前阶段不要求完整 UI,但至少要让 page/resource tabs 的 active、title、kind、dirty guard 状态可被统一读取和测试。
## 2. 允许修改范围
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- 可新增 `scripts/task47*-open-editors-*.js`
## 3. 禁止事项
- 不引入完整 VSCode editor group service。
- 不实现多 editor group grid。
- 不改变 Page Aggregate / local Markdown 事实源。
- 不破坏现有 resource tab 打开、关闭和 URL state。
## 4. Checklist
- [x] 暴露轻量 open editors snapshotpage tab + resource tabs。
- [x] snapshot 至少包含 `objectIdentity``title``kind``active``dirtyGuard`
- [x] active tab 切换时 snapshot 更新。
- [x] resource tab close / create 时 snapshot 更新。
- [ ] 如做 UI,保持低干扰,不新增复杂 sidebar 分组。
- [x] 补测试或 smoke,验证可读取 active editor 列表。
## 5. 验收
- `cargo test -p mnote-web web_shell -- --test-threads=1`
- 如新增 smoke`node scripts/task47*-open-editors-*.js`
## 6. 非目标
- 不做 drag editor between groups。
- 不做完整 dirty working copy service。
## 7. 执行记录
- 2026-05-21Codex 补齐 `mnote.open_editors_snapshot.v1` 轻量合同,暴露 `window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()`,并同步 `window.__mnoteOpenEditorsSnapshot` / `data-mnote-open-editors-count` / `data-mnote-active-editor`
- 2026-05-21snapshot 覆盖 page tab 与 resource tabsresource tab 条目包含 `objectIdentity``title``kind``badgeKind``active``dirtyGuard``assetId``path`
- 2026-05-21:未新增 sidebar UI,当前阶段先保持为可观测模型。
- 2026-05-21`cargo test -p mnote-web web_shell -- --test-threads=1` 通过。
- Reasonix Worker A 本轮只停留在探索,未产生可合入 diff;最终由 Codex 本地实现。
@@ -0,0 +1,74 @@
# 5-25 Editor Tab Keyboard and MRU Checklist v1
> 状态:process
>
> 日期:2026-05-21
>
> Ownermain editor tab keyboard / MRU behavior
## 1. 目标
参考 Sidex / VSCode `MultiEditorTabsControl`,补 MNote main tab 的基础键盘和 MRU 行为。当前只做轻量交互,不照搬完整 tab control。
## 2. 允许修改范围
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- 可新增聚焦 smoke
## 3. 禁止事项
- 不新增全局快捷键与浏览器默认冲突。
- 不实现完整 tab overflow 复杂布局。
- 不改变 resource tab identity。
## 4. Checklist
- [x] tab strip 支持左右箭头 roving focus。
- [x] `Enter` / `Space` 激活当前 focused tab。
- [x] 关闭 active resource tab 后回到 MRU 或 page tab,行为稳定。
- [x] tab 上保留可测试 DOM 状态。
- [x] 补测试或 smoke。
## 5. 验收
- `cargo test -p mnote-web web_shell -- --test-threads=1`
- 如新增 smoke`node scripts/task47*-editor-tab-keyboard-*.js`
## 6. 执行记录
> 引用 proposal`.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md`
### 已确认
- `resourceTabMru` 数组 + `touchResourceTabMru` / `lastActiveResourceTabKey` / `removeFromResourceTabMru` 逻辑完整,MRU 核心逻辑无需改动。
- `activateMainEditorTab` 已正确维护 roving tabindexactive → `tabindex="0"`, others → `tabindex="-1"`)。
- `createResourceTabDom` 中 resource tab 初始 `tabindex="-1"` 符合预期。
### 已实现
- [x] 新增 `bindMainEditorTabStrip()` 函数(keydown 事件委托 + collectTabs + 箭头/Home/End/Enter/Space)。
- [x] 修改 `closeResourceTab()` 末尾,关闭后 focus 迁移到新激活的 tab。
- [x]`styles.rs` 补充 `:focus-visible` 样式。
- [x] 更新 Rust contract test 断言。
- [x] `cargo test -p mnote-web web_shell -- --test-threads=1` 通过。
### 后续可选
- [ ] 如后续需要浏览器级回归,可新增 `scripts/task475-editor-tab-keyboard-mru-smoke.js`,覆盖多 tab 键盘切换与关闭后 focus。
### 非预期发现
- `closeResourceTab` 在 activate 后未显式调用 `focus()`,导致 keyboard focus 丢失。需修复。
- tab strip 无 `:focus-visible` 样式,键盘用户在箭头导航后看不到 focus 环。
### Codex 实施记录
- 2026-05-21Reasonix Worker B 按要求只输出 proposal,未直接改源码。
- 2026-05-21Codex 按 proposal 的最小安全集实现键盘 roving focus、Enter/Space 激活、关闭后 MRU/page tab focus 迁移。
- 2026-05-21:未新增全局快捷键;事件只绑定在 `[data-mnote-main-tab-strip]` 内。
## 7. 非目标
- 不做拖拽 tab 排序。
- 不做 close others / close saved 复杂菜单。
@@ -0,0 +1,46 @@
# 5-26 Resource Open Resolver Convergence Checklist v1
> 状态:process
>
> 日期:2026-05-21
>
> Ownerresource open resolver / attachment and filetree open targets
## 1. 目标
继续参考 Sidex 的 editor input resolver 思路,把 MNote 正文附件、FileTree asset、side target、new-window 的资源打开策略收敛到更一致的 resolver 合同。
## 2. 允许修改范围
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs` 仅限提出 patch 建议,默认不直接修改,避免与 5-24/5-25 冲突。
- `scripts/task463-onlyoffice-resolver-smoke.js`
## 3. 禁止事项
- 不改变文件树当前已验证可用的 edit-mode active tab 行为。
- 不把 md/text/code 改成 OnlyOffice 或浏览器新窗口默认打开。
- 不把 resource kind 判断散回多个 UI 分支。
## 4. Checklist
- [ ] 审查 `buildLocalOnlyOfficeOpenUrl``openEditorAttachmentEditTab``openConvexAssetFromFileTree``resolveResourceOpen` 的重复判断。
- [ ] 给出最小收敛方案,优先减少正文附件与 FileTree 的分歧。
- [ ] 若可安全实现,只改 `layout.rs` / `task463` 内重复逻辑。
- [ ] 补 smoke 或断言,覆盖正文附件 `弹窗编辑``新窗口编辑` 分流。
## 5. 验收
- `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1`
- `node scripts/task463-onlyoffice-resolver-smoke.js`
## 6. 非目标
- 不引入新的 editor service 层。
- 不改 OnlyOffice callback 写回链路。
## 7. 本轮执行记录
- 2026-05-21Reasonix Worker C 对照了 `openConvexAssetFromFileTree``openEditorAttachmentDetail``openEditorAttachmentNewWindow``openEditorAttachmentEditTab` 的重复 local Office 打开分支,并提交了“新增 `openLocalOfficeFileInActiveTab` 辅助函数”的计划。
- 2026-05-21Worker C 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 `layout.rs` / `task463`;本轮不把 5-26 checklist 标记为完成。
- 后续建议:若继续推进 5-26,应先用 Codex 本地复核 helper 边界,再小步改 `layout.rs`,重点保持“正文附件弹窗编辑进 main resource tab、正文附件新窗口编辑进浏览器窗口、md/text/code 继续走 tiptap”三条已验证行为不回退。
@@ -0,0 +1,63 @@
# 14 Sidex / MNote Workbench Gap Follow-up Review v1
> 状态:process
>
> 日期:2026-05-21
>
> 范围:在 12 号 review 已覆盖的基础上,继续对照 Sidex / VSCode workbench,收口当前仍最有价值的 editor/workbench 差距。
## 1. 本轮背景
上一轮对照已经把 P0/P1 的大方向列出,也推动了 main resource tab、Office local writeback、side target、filetree action layer 的若干补齐。当前需要继续沿 Sidex / VSCode 对照,找出仍值得优先参考的 workbench 细节,避免把精力花在已经收口或不值得照搬的部分。
## 2. 当前已收口或已在推进的部分
- `resourceTabRegistry` 已有基础的 active tab / close / URL state 行为。
- `openTarget=side` 已有轻量 side target 合同。
- `mindmap` 已能进入 main editor resource tab。
- `open/edit` 相关 Office 行为已区分默认 view、显式 edit、new-window 与 main resource tab。
- local folder sort order、close guard、filetree readonly action layer 已有对应 checklist。
## 3. 仍值得继续参考 Sidex 的切面
### 3.1 Open Editors 视图 / tab 模型聚合
VSCode 的 `OpenEditorsView` 不是单纯 tab strip,而是和 group model、dirty state、label refresh、active focus、drag and context menu 绑在一起。MNote 目前有 `resourceTabRegistry` 和 DOM tab strip,但还缺少一个更像“可被 Sidebar / 搜索 / 快捷键复用”的轻量 open editors 聚合视图。
### 3.2 tab 键盘 / MRU / 上下文菜单动作一致性
VSCode 的 tab 组支持聚焦、左右切换、关闭、关闭其他、固定、上下文菜单等。MNote 当前主要靠鼠标点击和少量 close 行为,键盘 roving / MRU / group context action 仍可参考。
### 3.3 资源打开目标的 resolver 集中化
Sidex 的 Explorer 不直接决定具体 editor 类型,而是统一交给 editor service + editor input resolver。MNote 现在的资源 open 逻辑已收拢不少,但正文附件、FileTree、side target、new-window 仍有分支,需要继续收敛到一个更明确的 resolver 入口。
### 3.4 资源生命周期与 filetree / open editors 联动
VSCode 的 Open Editors / Explorer / working copy / dirty indicator 是一条链。MNote 需要继续确认:
- tab 关闭和 dirty / saving / conflict 提示
- filetree active row 与 resource tab active state 的联动
- open editors 视图是否值得在当前阶段做成轻量聚合
## 4. 不建议继续照搬的部分
- 不要直接引入完整多 group grid。
- 不要照搬完整 DI / service container。
- 不要把 Monaco 文本模型当成 MNote 主事实源。
- 不要把 VSCode 插件宿主体系当成当前主线。
## 5. 本轮执行拆分
拆为四个方向并行推进:
- `design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md`
- `design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md`
- `design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md`
- `design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md`
## 6. 预期结果
- 先用 review / checklist 明确还值得参考的 Sidex 切面。
- 再派发多个 Reasonix 并行执行。
- 由 Codex 复核是否真的能对齐 MNote 的主线,而不是把 VSCode 全量搬过来。
+106 -2
View File
@@ -3343,6 +3343,62 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
};
const openEditorsSnapshotEntry = (entry, key) => ({
objectIdentity: String(entry?.objectIdentity || key || '').trim(),
title: String(entry?.title || '资源').trim() || '资源',
kind: normalizeResourceTabKind(entry),
badgeKind: entry?.tab instanceof HTMLElement
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
: resourceTabBadgeKind(entry, entry?.kind),
active: entry?.tab instanceof HTMLElement
? entry.tab.getAttribute('aria-selected') === 'true'
: false,
dirtyGuard: resourceTabCloseGuardReason(entry?.session),
assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(),
path: String(entry?.path || entry?.session?.resourcePath || '').trim(),
});
const buildOpenEditorsSnapshot = () => {
const nodes = resourceTabHostNodes();
const pageTitle = nodes.pageTab instanceof HTMLElement
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
: '页面';
const pageEntry = {
objectIdentity: 'page',
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId() || '').trim(),
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
title: pageTitle || '页面',
kind: 'page',
badgeKind: 'code',
active: nodes.pageTab instanceof HTMLElement
? nodes.pageTab.getAttribute('aria-selected') === 'true'
: false,
dirtyGuard: '',
};
const resources = [];
resourceTabRegistry.forEach((entry, key) => {
resources.push(openEditorsSnapshotEntry(entry, key));
});
return {
schema: 'mnote.open_editors_snapshot.v1',
generatedAt: Date.now(),
activeObjectIdentity: pageEntry.active
? 'page'
: (resources.find((entry) => entry.active)?.objectIdentity || ''),
editors: [pageEntry, ...resources],
resourceEditors: resources,
};
};
const syncOpenEditorsSnapshot = () => {
const snapshot = buildOpenEditorsSnapshot();
window.__mnoteOpenEditorsSnapshot = snapshot;
document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length));
document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || '');
window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot }));
return snapshot;
};
const showResourceTabCloseGuardNotice = (entry, reason) => {
const nodes = resourceTabHostNodes();
const messages = {
@@ -3445,17 +3501,56 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
syncActiveResourceFileTreeRow(activeResource);
syncActiveResourceUrlState(activeResource);
syncOpenEditorsSnapshot();
};
const bindMainEditorTabStrip = () => {
const nodes = resourceTabHostNodes();
if (!(nodes.strip instanceof HTMLElement)) return;
if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]'))
.filter((tab) => tab instanceof HTMLElement && tab.isConnected);
const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
nodes.strip.addEventListener('keydown', (event) => {
const tabs = collectTabs();
if (!tabs.length) return;
const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement);
const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs));
let targetIndex = -1;
if (event.key === 'ArrowRight') {
targetIndex = (baseIndex + 1) % tabs.length;
} else if (event.key === 'ArrowLeft') {
targetIndex = (baseIndex - 1 + tabs.length) % tabs.length;
} else if (event.key === 'Home') {
targetIndex = 0;
} else if (event.key === 'End') {
targetIndex = tabs.length - 1;
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const tab = tabs[baseIndex];
if (tab instanceof HTMLElement) tab.click();
return;
} else {
return;
}
event.preventDefault();
const target = tabs[targetIndex];
if (target instanceof HTMLElement) target.focus();
});
};
const bindMainEditorPageTab = () => {
const nodes = resourceTabHostNodes();
if (!(nodes.pageTab instanceof HTMLElement)) return;
bindMainEditorTabStrip();
if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return;
nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true');
nodes.pageTab.addEventListener('click', (event) => {
event.preventDefault();
activateMainEditorTab('');
});
syncOpenEditorsSnapshot();
};
const closeResourceTab = (objectIdentity) => {
@@ -3481,7 +3576,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
activateMainEditorTab(lastActiveResourceTabKey());
const nextKey = lastActiveResourceTabKey();
activateMainEditorTab(nextKey);
const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes().pageTab;
if (nextTab instanceof HTMLElement) nextTab.focus();
};
const markResourceTabError = (entry) => {
@@ -3861,6 +3959,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;
},
getOpenEditorsSnapshot: () => buildOpenEditorsSnapshot(),
refreshDocument: async ({ documentId, workspaceId, source } = {}) => {
const targets = Array.from(documentSessionRegistry.values()).filter((session) => (
sessionMatchesDocumentWorkspace(session, documentId, workspaceId)
@@ -4542,6 +4641,10 @@ mod tests {
assert!(html.contains("pageTab.setAttribute('data-document-id', documentId);"));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("mnote.open_editors_snapshot.v1"));
assert!(html.contains("getOpenEditorsSnapshot"));
assert!(html.contains("bindMainEditorTabStrip"));
assert!(html.contains("data-mnote-tab-strip-bound"));
assert!(html.contains("positionSlashMenuForRoot"));
assert!(html.contains("menu.style.position = 'fixed';"));
assert!(html.contains("installGlobalSlashMenuPositioning();"));
@@ -4592,7 +4695,8 @@ mod tests {
assert!(html.contains("resourceTabCloseGuardReason"));
assert!(html.contains("closeResourceTab"));
assert!(html.contains("resourceTabRegistry.delete(key)"));
assert!(html.contains("activateMainEditorTab(lastActiveResourceTabKey())"));
assert!(html.contains("const nextKey = lastActiveResourceTabKey();"));
assert!(html.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>"));
assert!(html.contains("if (!entry || entry.kind !== 'office') return false;"));
+5
View File
@@ -2411,6 +2411,11 @@ body {
color: #1B1C1C;
}
.mnote-main-tab:focus-visible {
outline: 2px solid #2F80ED;
outline-offset: -2px;
}
.mnote-main-tab.is-active {
background: #FFF;
color: #1B1C1C;