From fd94792ea671582210df506b39fc3ff871b999e8 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 21 May 2026 05:57:19 +0800 Subject: [PATCH] align workbench open editors tabs --- .../2026-05-21-tab-keyboard-mru-proposal.md | 339 ++++++++++++++++++ ...-05-21-sidex-worker-a-open-editors-view.md | 41 +++ ...6-05-21-sidex-worker-b-tab-keyboard-mru.md | 43 +++ ...1-sidex-worker-c-resource-open-resolver.md | 42 +++ ...sidex-worker-d-filetree-lifecycle-smoke.md | 38 ++ ...pen-editors-lifecycle-link-checklist-v1.md | 49 +++ ...n-editors-lightweight-view-checklist-v1.md | 51 +++ ...ditor-tab-keyboard-and-mru-checklist-v1.md | 74 ++++ ...-open-resolver-convergence-checklist-v1.md | 46 +++ ...-sidex-workbench-gap-followup-review-v1.md | 63 ++++ rust/crates/mnote-web/src/routes/web_shell.rs | 108 +++++- rust/crates/mnote-web/src/ssr/styles.rs | 5 + 12 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 .codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md create mode 100644 .codex/reasonix-tasks/2026-05-21-sidex-worker-a-open-editors-view.md create mode 100644 .codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md create mode 100644 .codex/reasonix-tasks/2026-05-21-sidex-worker-c-resource-open-resolver.md create mode 100644 .codex/reasonix-tasks/2026-05-21-sidex-worker-d-filetree-lifecycle-smoke.md create mode 100644 design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md create mode 100644 design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md create mode 100644 design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md create mode 100644 design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md create mode 100644 design/10-review/process/14-sidex-workbench-gap-followup-review-v1.md diff --git a/.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md b/.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md new file mode 100644 index 00000000..9476dba2 --- /dev/null +++ b/.codex/reasonix-proposals/2026-05-21-tab-keyboard-mru-proposal.md @@ -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"` | 当前可聚焦 tab(roving) | 与 `aria-selected` 同步 | +| `tabindex="-1"` | 不可直接聚焦 tab | 其他所有 tab | +| `data-mnote-main-tab="page"` | page tab 标识 | SSR 初始 | +| `data-mnote-main-tab=""` | 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 滚动 diff --git a/.codex/reasonix-tasks/2026-05-21-sidex-worker-a-open-editors-view.md b/.codex/reasonix-tasks/2026-05-21-sidex-worker-a-open-editors-view.md new file mode 100644 index 00000000..d7852a0b --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-sidex-worker-a-open-editors-view.md @@ -0,0 +1,41 @@ +# Reasonix Worker A:Sidex 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 合同 +- 验证命令结果 +- 未完成项和风险 diff --git a/.codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md b/.codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md new file mode 100644 index 00000000..329de69b --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-sidex-worker-b-tab-keyboard-mru.md @@ -0,0 +1,43 @@ +# Reasonix Worker B:Sidex 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 文件 +- 建议修改点 +- 认为最小安全实现的步骤 diff --git a/.codex/reasonix-tasks/2026-05-21-sidex-worker-c-resource-open-resolver.md b/.codex/reasonix-tasks/2026-05-21-sidex-worker-c-resource-open-resolver.md new file mode 100644 index 00000000..e2834354 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-sidex-worker-c-resource-open-resolver.md @@ -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` + +## 输出要求 + +最终回复列出: + +- 修改文件 +- 收敛了哪些重复分支 +- 验证命令结果 +- 未完成项和风险 diff --git a/.codex/reasonix-tasks/2026-05-21-sidex-worker-d-filetree-lifecycle-smoke.md b/.codex/reasonix-tasks/2026-05-21-sidex-worker-d-filetree-lifecycle-smoke.md new file mode 100644 index 00000000..5d365943 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-sidex-worker-d-filetree-lifecycle-smoke.md @@ -0,0 +1,38 @@ +# Reasonix Worker D:FileTree / 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 的更新 diff --git a/design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md b/design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md new file mode 100644 index 00000000..5c597b47 --- /dev/null +++ b/design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md @@ -0,0 +1,49 @@ +# 4-45 FileTree / Open Editors Lifecycle Link Checklist v1 + +> 状态:process +> +> 日期:2026-05-21 +> +> Owner:filetree 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-21:Reasonix Worker D 识别到 `task430` 的前置问题:新注册用户在 `workspaces:fetchWorkspaceSummaries` 前可能还没有 workspace,需要先通过 `/api/tree/commands` create action 触发 workspace bootstrap。 +- 2026-05-21:Worker D 还建议将 `task471` 的验证记录同步到 `4-44`,并补 open editor / lifecycle 状态断言。 +- 2026-05-21:Worker D 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 smoke 或 checklist;本轮不把 4-45 checklist 标记为完成。 +- 后续建议:先修 `task430` workspace bootstrap,再新增 filetree active row 与 open editors snapshot 的联合断言,避免只验证树动作、不验证打开编辑器状态。 diff --git a/design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md b/design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md new file mode 100644 index 00000000..08c4c035 --- /dev/null +++ b/design/05-editor-mainline/process/5-24-open-editors-lightweight-view-checklist-v1.md @@ -0,0 +1,51 @@ +# 5-24 Open Editors Lightweight View Checklist v1 + +> 状态:process +> +> 日期:2026-05-21 +> +> Owner:main 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 snapshot:page 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-21:Codex 补齐 `mnote.open_editors_snapshot.v1` 轻量合同,暴露 `window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()`,并同步 `window.__mnoteOpenEditorsSnapshot` / `data-mnote-open-editors-count` / `data-mnote-active-editor`。 +- 2026-05-21:snapshot 覆盖 page tab 与 resource tabs;resource 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 本地实现。 diff --git a/design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md b/design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md new file mode 100644 index 00000000..8e3ee9f7 --- /dev/null +++ b/design/05-editor-mainline/process/5-25-editor-tab-keyboard-and-mru-checklist-v1.md @@ -0,0 +1,74 @@ +# 5-25 Editor Tab Keyboard and MRU Checklist v1 + +> 状态:process +> +> 日期:2026-05-21 +> +> Owner:main 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 tabindex(active → `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-21:Reasonix Worker B 按要求只输出 proposal,未直接改源码。 +- 2026-05-21:Codex 按 proposal 的最小安全集实现键盘 roving focus、Enter/Space 激活、关闭后 MRU/page tab focus 迁移。 +- 2026-05-21:未新增全局快捷键;事件只绑定在 `[data-mnote-main-tab-strip]` 内。 + +## 7. 非目标 + +- 不做拖拽 tab 排序。 +- 不做 close others / close saved 复杂菜单。 diff --git a/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md b/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md new file mode 100644 index 00000000..191ea0b4 --- /dev/null +++ b/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md @@ -0,0 +1,46 @@ +# 5-26 Resource Open Resolver Convergence Checklist v1 + +> 状态:process +> +> 日期:2026-05-21 +> +> Owner:resource 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-21:Reasonix Worker C 对照了 `openConvexAssetFromFileTree`、`openEditorAttachmentDetail`、`openEditorAttachmentNewWindow`、`openEditorAttachmentEditTab` 的重复 local Office 打开分支,并提交了“新增 `openLocalOfficeFileInActiveTab` 辅助函数”的计划。 +- 2026-05-21:Worker C 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 `layout.rs` / `task463`;本轮不把 5-26 checklist 标记为完成。 +- 后续建议:若继续推进 5-26,应先用 Codex 本地复核 helper 边界,再小步改 `layout.rs`,重点保持“正文附件弹窗编辑进 main resource tab、正文附件新窗口编辑进浏览器窗口、md/text/code 继续走 tiptap”三条已验证行为不回退。 diff --git a/design/10-review/process/14-sidex-workbench-gap-followup-review-v1.md b/design/10-review/process/14-sidex-workbench-gap-followup-review-v1.md new file mode 100644 index 00000000..83952006 --- /dev/null +++ b/design/10-review/process/14-sidex-workbench-gap-followup-review-v1.md @@ -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 全量搬过来。 diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 106de1d5..84f816e7 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -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;")); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index c0880440..b8a54240 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -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;