diff --git a/.agents/notes/implemented/architecture/2026-10-30-windows-editor-surface-reuse.md b/.agents/notes/implemented/architecture/2026-10-30-windows-editor-surface-reuse.md new file mode 100644 index 0000000000..bfe8c8d71b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-30-windows-editor-surface-reuse.md @@ -0,0 +1,83 @@ +# Agent 笔记:Windows 编辑器表面复用与标签上限 + +状态:已实现 + +## 先说结论 + +Windows 每个窗格最多保留 8 个已挂载的编辑器表面(surface,一个 Monaco 编辑器实例及其 DOM)。打开第 9 个文件时不再销毁并新建编辑器,而是把最久未用的表面改绑到新文件,只切换模型(model,Monaco 里保存一个文件文本的对象)。 + +这样打开文件省掉了建编辑器的成本,代价是组件不再随文件重建:凡是属于某个文件的状态,都必须在模型绑定的 effect 里建立和清理。标签上限是另一件事:它决定标签栏里留多少标签,默认 30,全部逻辑只有一份,在 `buffer-eviction.ts`。 + +## 问题 + +打开文件时,建编辑器的成本是 70~100 毫秒,还要运行全部 Monaco 贡献点(contribution)。隐藏的标签也会拖慢其他操作:WebView2 给 `visibility: hidden` 的 Monaco 树重算样式比可见时慢很多,在隐藏标签旁打开文件要多花 100~250 毫秒。 + +标签上限当时有两份实现,而且都不完整: + +- `buffer.store.ts` 的淘汰路径用的是 store 里的 `maxOpenTabs` 副本。这个副本取自默认值,设置页改动从来同步不到,所以用户设的上限和策略都不生效,0 也不代表不限。 +- `tab-bar.tsx` 的 effect 读真实设置,但只排除已固定、未保存和当前标签,不排除终端和 Agent 标签。默认值是 100 时几乎碰不到,默认值降低后,打开新标签就可能关掉用户的终端。 + +## 决策 + +### 编辑器表面 + +`pane-container.tsx` 用 `assignEditorSurfaceKeys` 给挂载中的文件分配稳定的 surface key,离开挂载集合的文件把 key 让给下一个文件。React 因此保留同一个组件实例,`MonacoEditor` 只执行一次 `monacoEditor.create`。 + +开发者要按这个规则放代码: + +- 编辑器级的处理函数放在创建 effect 里,通过 `modelBindingRef` 或 `editorCallbacksRef` 读取当前文件。 +- 模型级的东西放在模型 effect 里,随文件建立和清理,包括内容监听、跳转手势、Git gutter、调试装饰和打开的菜单。 +- 读 `modelRef` 的 effect 不能只依赖文件相关的值。文件文本还在恢复时 `isContentReady` 为 false,此时没有模型,所以依赖里要带上它,否则模型绑定后这个 effect 不会重跑。诊断标记和 Vim 适配器都因此依赖 `isContentReady`。 +- 新增按文件保存的 `useState` 时,要在模型 effect 的清理函数里重置。 + +不要这样做:在创建 effect 里直接闭包读取 `filePath`、`editorBufferId`,这些值在第一次切换文件后就过期了。 + +### 隐藏标签 + +隐藏的表面用 `content-visibility: hidden` 加 `visibility: hidden`。前者跳过内容的样式和布局,并保留缓存的尺寸,所以 Monaco 不会被缩成 0;后者防止空槽位接收点击。 + +隐藏表面仍然挂着模型,Monaco 会照常向提供方请求数据。`surface-visibility.ts` 记录每个模型的可见情况,CodeLens 和语义高亮对只被隐藏表面显示的模型跳过请求,显示时再刷新一次。语义高亮靠抛出含 `busy` 的错误保留现有 token,这是 Monaco 0.55 `documentSemanticTokens` 的既有行为,升级 Monaco 时要复查。 + +### 标签上限 + +`selectBuffersOverTabLimit` 是唯一的选择逻辑,打开新标签时由 store 调用,调低上限或恢复会话后由标签栏调用。store 直接读取设置,不再保存副本。 + +规则是:上限只计算可以自动关闭的标签;已固定、有未保存修改的标签,以及终端、Agent、外部编辑器、网页查看器和单例工具标签,既不计数也不关闭;0 表示不限制。 + +### 默认值 + +默认值定为 30。表面复用之后,标签数已经不是性能问题:用 `fileOpen` 同样的固定工程(每个文件几十行)测量,打开新文件的耗时在 8、20、30、40 个标签时分别约为 40、42、44、49 毫秒,每多一个标签约增加 0.2MB 堆内存。复用之前这组数字是 72、92、105、119 毫秒。 + +既然上限不再用来换性能,它就只是在“标签栏可控”和“不要替用户关文件”之间取舍。IDEA 默认的 10 在 Java 工程里偏紧,跳转几次定义就会把刚看的文件挤掉;100 基本等于不限制。30 比 20 只多约 2 毫秒和 2MB,却能明显减少自动关闭;到 40 时耗时开始上升。实际文件更大时内存会相应增加,这也是保留上限的原因。 + +`settings.json` 在首次启动时写入全部默认值,所以这个默认值只影响新安装。已有用户保存的 100 不会被改写,因为无法区分它是旧默认值还是用户的选择。 + +## 考虑过的备选方案 + +- 只把默认值改小,不合并两份实现:会让设置继续失效,并开始关闭终端和 Agent 标签,所以不采用。 +- 把淘汰逻辑全部放到标签栏:标签栏未挂载时就没有上限,而且关闭发生在渲染之后,所以打开时的淘汰仍留在 store。 +- 只保留 `visibility: hidden` 或卸载隐藏标签:前者样式成本高,后者每次切回都要重建编辑器。 +- 挂载更多表面:内存随表面数线性增长,8 个已能覆盖常见的来回切换。 + +## 后果 + +- 文件切换不再重建组件,遗漏重置的按文件状态会带到下一个文件,审查 `monaco-editor.tsx` 时要专门检查。 +- 超过 8 个标签后切回未挂载的文件仍要绑定模型,但不再新建编辑器。越过挂载上限打开文件从约 94 毫秒降到约 42 毫秒。 +- 切回已挂载标签的中位耗时从约 7 毫秒变成约 13 毫秒,因为不再在提交阶段同步布局,要等下一帧;P95 都是 16 毫秒,仍在一帧之内。 +- Monaco 的 `editor.addCommand` 不提供注销,处理函数会存活到进程结束。这里的处理函数不能捕获创建 effect 的作用域,否则每个销毁的编辑器都会被留在内存里,`leaks` 场景会把它测出来。 +- 卸载时 React 先清理创建 effect,所以创建 effect 的清理函数要先调用模型清理,否则编辑器已销毁,视口状态存不下来。 +- 标签上限对已有用户不产生变化,想要收敛的用户需要自己调低。 +- macOS 目前没有标签上限,功能矩阵 `editor-tab-limit` 记录了这个差异。 + +## 验证 + +```bash +./scripts/agent-notes/verify-agent-notes.sh +node scripts/windows/perf/run-windows-perf.mjs --only tabs,fileOpen,leaks +``` + +Windows CI 的 `windows-file-open-performance` 和 `windows-file-drop-listeners` 覆盖淘汰规则、表面 key、模型位置解析、隐藏模型跳过和日志批量发送。`fileOpen` 场景测量越过挂载上限后的打开耗时。 + +## 适用范围 + +适用于 `windows/tauri` 的编辑器窗格、标签栏和相关设置。macOS 使用原生编辑器结构,不受本笔记约束。 diff --git a/.agents/notes/implemented/testing/2026-10-30-windows-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-10-30-windows-frontend-performance-budgets.md index 3c35c5e5f3..1c816a5b09 100644 --- a/.agents/notes/implemented/testing/2026-10-30-windows-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-10-30-windows-frontend-performance-budgets.md @@ -19,7 +19,7 @@ Windows 版跑在 WebView2 里,真实的卡顿来自主线程上的 JavaScript ### 测什么 -`scenarios.mjs` 定义场景:启动、打开工程、小文件和 2 万行文件打字、连按、滚动、切标签、工程树、快速打开、命令面板,以及反复打开文件后的内存增长。指标包括按键到下一帧的延迟、每键主线程耗时、每键 IPC 字节数和保存次数。 +`scenarios.mjs` 定义场景:启动、打开工程、小文件和 2 万行文件打字、连按、滚动、切标签、越过挂载上限打开文件、工程树、快速打开、命令面板,以及反复打开文件后的内存增长。指标包括按键到下一帧的延迟、每键主线程耗时、每键 IPC 字节数和保存次数。 打字有两种节奏:匀速(`typingLarge`)和分组(`typingBursts`,每 8 个键停 400 毫秒)。后者才能暴露防抖后的工作,比如自动保存和工程索引,匀速打字时这些工作根本不会触发。 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 3886f7ac1f..7f52d226f2 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -281,6 +281,10 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-java-syntax-definition.json -- src/features/editor/lsp/java-syntax-definition.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-open-performance.json -- src/features/editor/engines/monaco/hover-widgets.test.ts src/features/editor/engines/monaco/model-position-resolver.test.ts src/features/editor/engines/monaco/surface-visibility.test.ts src/features/editor/engines/monaco/theme-definition-cache.test.ts src/features/editor/utils/workspace-index-overrides.test.ts src/features/editor/stores/buffer-eviction.test.ts src/features/tabs/utils/tab-recency.test.ts src/features/panes/utils/mounted-editor-buffers.test.ts src/features/file-explorer/lib/tree-guide-visibility.test.ts src/features/file-explorer/styles/file-explorer-tree-guides.test.ts src/features/mybatis/utils/mybatis-index-paths.test.ts src/features/logging/frontend-log-runtime.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-drop-listeners.json -- src/features/file-system/hooks/use-file-system-folder-drop.test.tsx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-debug-events.json -- src/features/debugger/services/debug-adapter-events.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # The event bridge mocks the adapter module; run the real adapter tests in a separate process. diff --git a/scripts/windows/perf/budgets.json b/scripts/windows/perf/budgets.json index 14abda3cc5..492404ec19 100644 --- a/scripts/windows/perf/budgets.json +++ b/scripts/windows/perf/budgets.json @@ -28,6 +28,7 @@ "scroll.frameP95Ms": 20, "scroll.droppedFramesPct": 2, "tabs.switchP95Ms": 33, + "fileOpen.pastLimitOpenP95Ms": 66, "projectTree.frameMaxMs": 33, "quickOpen.openMs": 50, "quickOpen.queryMs": 200, diff --git a/scripts/windows/perf/lib/harness.mjs b/scripts/windows/perf/lib/harness.mjs index 17a34895cc..b6e5b148c0 100644 --- a/scripts/windows/perf/lib/harness.mjs +++ b/scripts/windows/perf/lib/harness.mjs @@ -177,6 +177,16 @@ export class Harness { return milliseconds; } + /** Like `openFile`, but waits for `text` so a hidden editor's lines do not count. */ + async openFileToText(relative, text) { + const row = await this.revealRow(relative); + return this.inputToShown( + () => this.page.locator(this.rowSelector(row)).first().dblclick(), + EDITOR_LINES, + text, + ); + } + async focusEditor() { // The line layer scrolls away with the content; the guard stays in the viewport. await this.page.locator(".monaco-editor .overflow-guard").first().click({ position: { x: 300, y: 200 } }); diff --git a/scripts/windows/perf/scenarios.mjs b/scripts/windows/perf/scenarios.mjs index d7282cb270..b7454f2025 100644 --- a/scripts/windows/perf/scenarios.mjs +++ b/scripts/windows/perf/scenarios.mjs @@ -192,6 +192,41 @@ const tabs = { }, }; +// More files than the mounted editors, so the last opens reuse an editor. Each +// open waits for the file's own text: hidden editors' lines are already on screen. +const OPENED_FILE_COUNT = 12; +const MOUNTED_EDITOR_COUNT = 8; + +const classNameOf = (fileName) => fileName.replace(".java", ""); + +// The tree sorts by name and renders only its first rows. +function firstServiceFiles(count) { + return Array.from({ length: SERVICE_COUNT }, (_, index) => `Service${index}.java`) + .sort() + .slice(0, count); +} + +const fileOpen = { + id: "fileOpen", + title: `Open ${OPENED_FILE_COUNT} files, past the mounted editor limit`, + async run(harness) { + await setupProject(harness); + const opens = []; + for (const name of firstServiceFiles(OPENED_FILE_COUNT)) { + opens.push(await harness.openFileToText(`${SERVICE_DIRECTORY}/${name}`, classNameOf(name))); + await harness.quiet(150); + } + const counters = await harness.counters(); + return { + newEditorOpenP50Ms: percentile(opens.slice(1, MOUNTED_EDITOR_COUNT), 50), + pastLimitOpenP50Ms: percentile(opens.slice(MOUNTED_EDITOR_COUNT), 50), + pastLimitOpenP95Ms: percentile(opens.slice(MOUNTED_EDITOR_COUNT), 95), + heapMb: counters.heapMb, + domNodes: counters.domNodes, + }; + }, +}; + const projectTree = { id: "projectTree", title: `Browse a folder of ${SERVICE_COUNT} files in the project tree`, @@ -282,6 +317,7 @@ export const SCENARIOS = [ keyRepeat, scroll, tabs, + fileOpen, projectTree, quickOpen, commandPalette, diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ced42c8246..0c4a6dcb23 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -7348,7 +7348,11 @@ "settings.editor.lintOnSave": "Lint on Save", "settings.editor.lintOnSaveDescription": "Run linter when saving files", "settings.editor.maxOpenTabs": "Max Open Tabs", - "settings.editor.maxOpenTabsDescription": "Maximum number of tabs before oldest closes", + "settings.editor.maxOpenTabsDescription": "Tabs close automatically past this count. Pinned and unsaved tabs, terminals, and agents do not count. 0 means no limit.", + "settings.editor.tabLimitPolicy": "When the Tab Limit Is Reached", + "settings.editor.tabLimitPolicyDescription": "Which tab closes when opening another tab would exceed the limit. Pinned tabs and tabs with unsaved changes are never closed.", + "settings.editor.tabLimitPolicy.leastRecentlyUsed": "Close least recently used", + "settings.editor.tabLimitPolicy.oldestOpened": "Close oldest opened", "settings.editor.parameterHints": "Parameter Hints", "settings.editor.parameterHintsDescription": "Show function parameter hints", "settings.editor.phase": "Phase", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 2863663076..b45d4f1ca7 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -7348,7 +7348,11 @@ "settings.editor.lintOnSave": "保存时检查", "settings.editor.lintOnSaveDescription": "保存文件时运行检查器", "settings.editor.maxOpenTabs": "最大打开标签数", - "settings.editor.maxOpenTabsDescription": "达到此数量后关闭最早的标签页", + "settings.editor.maxOpenTabsDescription": "标签超过此数量时自动关闭。已固定、未保存的标签以及终端和 Agent 不计入。0 表示不限制。", + "settings.editor.tabLimitPolicy": "超出标签上限时", + "settings.editor.tabLimitPolicyDescription": "打开新标签导致超出上限时关闭哪个标签。已固定和有未保存修改的标签不会被关闭。", + "settings.editor.tabLimitPolicy.leastRecentlyUsed": "关闭最久未使用的", + "settings.editor.tabLimitPolicy.oldestOpened": "关闭最早打开的", "settings.editor.parameterHints": "参数提示", "settings.editor.parameterHintsDescription": "显示函数参数提示", "settings.editor.phase": "渐变", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 1eb30303c2..9243fb345c 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -69265,6 +69265,34 @@ "windows" ] }, + { + "id": "settings.editor.tabLimitPolicy", + "description": "Windows UI: settings.editor.tabLimitPolicy", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicyDescription", + "description": "Windows UI: settings.editor.tabLimitPolicyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicy.leastRecentlyUsed", + "description": "Windows UI: settings.editor.tabLimitPolicy.leastRecentlyUsed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicy.oldestOpened", + "description": "Windows UI: settings.editor.tabLimitPolicy.oldestOpened", + "platforms": [ + "windows" + ] + }, { "id": "settings.editor.parameterHints", "description": "Windows UI: settings.editor.parameterHints", diff --git a/shared/platform-feature-matrix/features/editor-language-basics.json b/shared/platform-feature-matrix/features/editor-language-basics.json index 9e612b657f..bffd3ebbee 100644 --- a/shared/platform-feature-matrix/features/editor-language-basics.json +++ b/shared/platform-feature-matrix/features/editor-language-basics.json @@ -29,12 +29,14 @@ "windows/tauri/src/features/editor", "frontend/editor", "windows/tauri/src/features/editor/engines/monaco/theme.ts", - "frontend/editor/src/word-highlight-lifecycle.ts" + "frontend/editor/src/word-highlight-lifecycle.ts", + "windows/tauri/src/features/editor/engines/monaco/model-position-resolver.ts", + "windows/tauri/src/features/editor/engines/monaco/model-position-resolver.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Editor", - "notes": "macOS 对已关闭文档的过期焦点、光标和工具请求返回取消,避免锁住其他文档;文本增量、保存和缺失 ID 的非法消息仍失败。原生 WKWebView 回归直接编译生产消息策略,覆盖主视图与分屏的关闭顺序,Windows 独立宿主不改;完整产品与 Windows 实机验收仍 pending。 共享高亮生命周期钩子绑定上游实例的原模型,模型关闭或切换后不再执行旧回调;保持上游符号计算、同步失败保护与完整验收 pending。", - "verification": "分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。macOS 运行 scripts/macos/probe/probe-macos-monaco.sh --workbench-tests,验证原生关闭文档后到达的 Monaco 焦点和光标消息取消,主视图和分屏切换后不出现同步失败红条,其他文档仍可编辑并完成同步;已关闭文档的文本编辑与保存请求仍必须拒绝。真实产品另检查关闭文件、关闭搜索预览、删除文件后的文档切换及未保存输入保护。 原生探针捕获真实 Monaco 高亮回调,先在存活视图产生查询,再关闭主视图、分屏或差异视图,显式执行旧回调,确认没有空模型 URI 异常、存活文档仍可编辑且文本不变。升级固定 Monaco 版本时复核兼容钩子并运行三个回归;Windows 另在 WebView2 验证。 macOS Lithe 深色行号为 #4B5059,当前行为 #A1A3AB,行号为普通编辑字体小1pt且跟随字号设置。打开含 main 的 Java 文件确认标签原始类图标右上角显示 runnableMark,普通类和仅测试类不显示,删除 main 后更新;用现有 JDT Run marker 响应驱动,不增加语言服务请求。 macOS 编辑器边栏从左到右为行号、运行/导航标记;主方法使用原始16pt IDEA 描边 Run SVG 与明暗变体,断点在行号区显示并保持原点击调试请求。真实 WebKit 检查图标位置及引擎命中区域。Windows 未开启 lineNumbersFirst。 行号后及动作后留白均4pt,折叠按钮单独占16pt槽,原始9×9 IDEA fold/unfold SVG 保留明暗颜色与1pt描边;实际点击折叠/断点和运行图标互不串联,不影响差异审阅布局。 测试类未运行状态使用 IDEA 原始16pt Run All 明暗描边图标,测试方法和结果状态语义保持不变。没有导航标记时收回空导航槽,运行控件和折叠控件之间为4pt;导航标记出现、主题配置及切换文件后槽位仍正确。 普通行号栏按实际行号字体与最大行号位数计算,最低16pt用于断点,不固定预留5位;验证9/99/100/1000行增长及缩回、字体字号变更、分屏独立宽度,Git blame 维持注释空间并能恢复普通栏宽。 Java 测试文件标签在类图标右上角显示原始 IDEA JunitTestMark 红绿角标;测试与 main 共存时测试优先,移除测试或改为非 Java 后撤销,主方法与普通文件逻辑不变。由已有 JDT Run marker 响应驱动,不通过文件名推测测试。 macOS Lithe 深色结构线普通色 #323438、高亮色 #4E5157;仅光标紧邻多行代码块配对花括号时显示对应高亮线,块内普通语句、文本选择及字符串或注释中的括号不触发,分屏各自独立。原生 WebKit 回归覆盖实际引导线颜色、嵌套块与配置更新;完整产品视觉验收仍 pending。 macOS 普通行号列左偏移8pt(4pt空注释区+4pt间隔),数字列宽仍按实际字体和位数计算,断点不额外计入左偏移;真实WebKit验证9/99/100/1000行、字号/字体、分屏、运行/折叠命中和Git blame恢复,行号至Run仍4pt,未出现额外右侧空槽。 macOS软换行开关移入WebKit的标准HTML按钮,移除原生覆盖层的光标竞争;保留共享22pt外观、SVG、选中/禁用、查找栏隐藏及原设置持久化,真实WebKit验证完整命中区域均pointer、点击桥接与选区保持,缩略图开关后位置正确。 普通快速修复提示采用IDEA原始16pt intentionBulb明暗SVG,覆盖代码区与gutter位置,保留原Monaco动作和菜单。 macOS Java 首次打开文件时默认折叠至少两条 import/static import,Java 服务准备期间也可用;类体与方法默认展开,手动展开后切换标签保持展开,单条导入不折叠。复用 Core Java structure 和 Monaco foldingImportsByDefault,真实 WebKit 检查可见折叠状态与源码不变,Swift 检查 Core 范围;Windows 未启用。 macOS Java 主编辑区和分屏初次附加模型时,等待 Monaco 初始折叠与视图状态恢复再显示内容,避免展开导入区随后收起的闪动;无固定延时。真实 WebKit 用受控 folding provider 验证计算期间不呈现展开代码、完成后首个可见状态已折叠。 Java TextMate 注释分隔符 punctuation.definition.comment 映射为 comment,斜杠与正文同色;WebKit 深浅主题验证行/块/文档注释前景色与 token 类型一致,除法运算符不受影响。" + "notes": "macOS 对已关闭文档的过期焦点、光标和工具请求返回取消,避免锁住其他文档;文本增量、保存和缺失 ID 的非法消息仍失败。原生 WKWebView 回归直接编译生产消息策略,覆盖主视图与分屏的关闭顺序,Windows 独立宿主不改;完整产品与 Windows 实机验收仍 pending。 共享高亮生命周期钩子绑定上游实例的原模型,模型关闭或切换后不再执行旧回调;保持上游符号计算、同步失败保护与完整验收 pending。 Windows 编辑器表面复用时,浮层位置解析按调用时当前绑定的模型读取,覆盖初次加载、模型切换及解绑;自动回归不替代 WebView2 产品验收。", + "verification": "分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。macOS 运行 scripts/macos/probe/probe-macos-monaco.sh --workbench-tests,验证原生关闭文档后到达的 Monaco 焦点和光标消息取消,主视图和分屏切换后不出现同步失败红条,其他文档仍可编辑并完成同步;已关闭文档的文本编辑与保存请求仍必须拒绝。真实产品另检查关闭文件、关闭搜索预览、删除文件后的文档切换及未保存输入保护。 原生探针捕获真实 Monaco 高亮回调,先在存活视图产生查询,再关闭主视图、分屏或差异视图,显式执行旧回调,确认没有空模型 URI 异常、存活文档仍可编辑且文本不变。升级固定 Monaco 版本时复核兼容钩子并运行三个回归;Windows 另在 WebView2 验证。 macOS Lithe 深色行号为 #4B5059,当前行为 #A1A3AB,行号为普通编辑字体小1pt且跟随字号设置。打开含 main 的 Java 文件确认标签原始类图标右上角显示 runnableMark,普通类和仅测试类不显示,删除 main 后更新;用现有 JDT Run marker 响应驱动,不增加语言服务请求。 macOS 编辑器边栏从左到右为行号、运行/导航标记;主方法使用原始16pt IDEA 描边 Run SVG 与明暗变体,断点在行号区显示并保持原点击调试请求。真实 WebKit 检查图标位置及引擎命中区域。Windows 未开启 lineNumbersFirst。 行号后及动作后留白均4pt,折叠按钮单独占16pt槽,原始9×9 IDEA fold/unfold SVG 保留明暗颜色与1pt描边;实际点击折叠/断点和运行图标互不串联,不影响差异审阅布局。 测试类未运行状态使用 IDEA 原始16pt Run All 明暗描边图标,测试方法和结果状态语义保持不变。没有导航标记时收回空导航槽,运行控件和折叠控件之间为4pt;导航标记出现、主题配置及切换文件后槽位仍正确。 普通行号栏按实际行号字体与最大行号位数计算,最低16pt用于断点,不固定预留5位;验证9/99/100/1000行增长及缩回、字体字号变更、分屏独立宽度,Git blame 维持注释空间并能恢复普通栏宽。 Java 测试文件标签在类图标右上角显示原始 IDEA JunitTestMark 红绿角标;测试与 main 共存时测试优先,移除测试或改为非 Java 后撤销,主方法与普通文件逻辑不变。由已有 JDT Run marker 响应驱动,不通过文件名推测测试。 macOS Lithe 深色结构线普通色 #323438、高亮色 #4E5157;仅光标紧邻多行代码块配对花括号时显示对应高亮线,块内普通语句、文本选择及字符串或注释中的括号不触发,分屏各自独立。原生 WebKit 回归覆盖实际引导线颜色、嵌套块与配置更新;完整产品视觉验收仍 pending。 macOS 普通行号列左偏移8pt(4pt空注释区+4pt间隔),数字列宽仍按实际字体和位数计算,断点不额外计入左偏移;真实WebKit验证9/99/100/1000行、字号/字体、分屏、运行/折叠命中和Git blame恢复,行号至Run仍4pt,未出现额外右侧空槽。 macOS软换行开关移入WebKit的标准HTML按钮,移除原生覆盖层的光标竞争;保留共享22pt外观、SVG、选中/禁用、查找栏隐藏及原设置持久化,真实WebKit验证完整命中区域均pointer、点击桥接与选区保持,缩略图开关后位置正确。 普通快速修复提示采用IDEA原始16pt intentionBulb明暗SVG,覆盖代码区与gutter位置,保留原Monaco动作和菜单。 macOS Java 首次打开文件时默认折叠至少两条 import/static import,Java 服务准备期间也可用;类体与方法默认展开,手动展开后切换标签保持展开,单条导入不折叠。复用 Core Java structure 和 Monaco foldingImportsByDefault,真实 WebKit 检查可见折叠状态与源码不变,Swift 检查 Core 范围;Windows 未启用。 macOS Java 主编辑区和分屏初次附加模型时,等待 Monaco 初始折叠与视图状态恢复再显示内容,避免展开导入区随后收起的闪动;无固定延时。真实 WebKit 用受控 folding provider 验证计算期间不呈现展开代码、完成后首个可见状态已折叠。 Java TextMate 注释分隔符 punctuation.definition.comment 映射为 comment,斜杠与正文同色;WebKit 深浅主题验证行/块/文档注释前景色与 token 类型一致,除法运算符不受影响。 Windows 连续打开超过 8 个文件以触发表面复用,随后在新文件执行重命名、查看参数提示和 Code Lens;延迟加载结束后与切换为较短文件后定位均正确,旧模型释放或解绑时不返回旧位置。" } diff --git a/shared/platform-feature-matrix/features/editor-tab-limit.json b/shared/platform-feature-matrix/features/editor-tab-limit.json new file mode 100644 index 0000000000..bf3197213b --- /dev/null +++ b/shared/platform-feature-matrix/features/editor-tab-limit.json @@ -0,0 +1,27 @@ +{ + "id": "editor-tab-limit", + "area": "编辑器", + "group": "文本编辑", + "capability": "标签数量上限与自动关闭策略", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Editor" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/editor/stores/buffer-eviction.ts", + "windows/tauri/src/features/editor/stores/buffer-eviction.test.ts", + "windows/tauri/src/features/tabs/utils/tab-recency.ts", + "windows/tauri/src/features/tabs/components/tab-bar.tsx", + "windows/tauri/src/features/settings/components/tabs/editor-settings.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Editor", + "notes": "Windows 只有一份上限逻辑:打开新标签时由 buffer store 淘汰,调低上限或恢复会话时由标签栏补齐。上限只计算可自动关闭的标签,已固定、有未保存修改的标签以及终端、Agent 等工具标签既不计数也不关闭;0 表示不限制。默认值 30 只影响新安装,已写入 settings.json 的值保持不变。", + "verification": "Windows:把最大打开标签数设为 3,依次打开 5 个文件并切回第 1 个,确认默认策略关闭最久未使用的标签而不是最早打开的;切换为「关闭最早打开的」后行为随之改变。开着终端、Agent 对话、已固定和未保存标签重复上述步骤,确认它们都不被关闭。把上限设为 0 后继续打开文件,确认不再自动关闭。" +} diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index f9f004979d..1d49481c71 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -735,9 +735,12 @@ pub fn get_bundled_extensions_path(app: AppHandle) -> Result { .map_err(|error| error.to_string()) } +/// Returns raw bytes instead of serializing each byte as a JSON array element. #[tauri::command] -pub async fn read_local_file(path: PathBuf) -> Result, String> { - run_blocking(move || fs::read(path).map_err(|error| error.to_string())).await +pub async fn read_local_file(path: PathBuf) -> Result { + run_blocking(move || fs::read(path).map_err(|error| error.to_string())) + .await + .map(tauri::ipc::Response::new) } fn read_bounded(reader: &mut impl Read, max_bytes: usize) -> io::Result { diff --git a/windows/tauri/src-tauri/src/logging.rs b/windows/tauri/src-tauri/src/logging.rs index 6ab4a5420c..6fc7ffe8c5 100644 --- a/windows/tauri/src-tauri/src/logging.rs +++ b/windows/tauri/src-tauri/src/logging.rs @@ -2456,6 +2456,31 @@ pub fn frontend_trace( manager.emit_json(&level, scope, message, payload); } +/// One frontend log entry inside a [`frontend_trace_batch`] call. +#[derive(Debug, Deserialize)] +pub struct FrontendTraceEntry { + level: String, + scope: String, + message: String, + payload: Option, +} + +/// Records frontend log entries collected since the last flush. +/// +/// The frontend queues its logs and sends them together once the window is +/// idle, so logging during a file open or keystroke costs no IPC round trip. +/// The command is async so the batch never waits for the UI thread. +#[tauri::command] +pub async fn frontend_trace_batch( + entries: Vec, + manager: State<'_, Arc>, +) -> Result<(), String> { + for entry in entries { + manager.emit_json(&entry.level, entry.scope, entry.message, entry.payload); + } + Ok(()) +} + #[tauri::command] pub fn record_startup_milestone(milestone: String, manager: State<'_, Arc>) { manager.emit(LogLevel::Info, "startup", milestone, BTreeMap::new()); diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 69a2efac0b..31be711fd3 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -246,6 +246,7 @@ fn main() { logging::resolve_previous_log_cleanup, logging::open_log_directory, logging::frontend_trace, + logging::frontend_trace_batch, logging::record_startup_milestone, host::get_system_theme, host::set_native_window_appearance, diff --git a/windows/tauri/src/features/editor/components/code-editor.tsx b/windows/tauri/src/features/editor/components/code-editor.tsx index ef1d2488ae..47c68b9f13 100644 --- a/windows/tauri/src/features/editor/components/code-editor.tsx +++ b/windows/tauri/src/features/editor/components/code-editor.tsx @@ -68,7 +68,7 @@ import { type MarkdownScrollMetrics, } from "../markdown/scroll-sync"; import { SvgEditor } from "./svg-editor"; -import { EditorStylesheet } from "./stylesheet"; +import "../styles/editor-shell.css"; import { ExternalConflictBanner } from "./external-conflict-banner"; import Breadcrumb, { type BreadcrumbProps } from "./toolbar/breadcrumb"; @@ -81,6 +81,8 @@ interface CodeEditorProps { paneId?: string; bufferId?: string; isActiveSurface?: boolean; + /** Shown in its pane, focused or not; defaults to `isActiveSurface`. */ + isVisibleSurface?: boolean; showToolbar?: boolean; readOnly?: boolean; breadcrumbProps?: BreadcrumbProps; @@ -152,6 +154,7 @@ const CodeEditor = ({ paneId, bufferId: propBufferId, isActiveSurface = true, + isVisibleSurface = isActiveSurface, showToolbar = false, readOnly = false, breadcrumbProps, @@ -246,18 +249,31 @@ const CodeEditor = ({ const showNotebookEditor = activeBuffer?.type === "editor" && filePath.toLowerCase().endsWith(".ipynb"); const [forceExpensiveServices, setForceExpensiveServices] = useState(false); + // Editor surfaces are reused across files; the large-file override belongs + // to the file it was granted for. + const [forcedServicesBufferId, setForcedServicesBufferId] = useState(activeBufferId); + if (forcedServicesBufferId !== activeBufferId) { + setForcedServicesBufferId(activeBufferId); + setForceExpensiveServices(false); + } const tooLargeForEditorServices = useEditorViewStore( (state) => state.tooLargeForEditorServices === true, ); - const enableInteractiveServices = - isActiveSurface && + const supportsInteractiveServices = !isPreviewBuffer && !readOnly && !showNotebookEditor && !isRestorePending && !hasRestoreLoadError; + const enableInteractiveServices = isActiveSurface && supportsInteractiveServices; const enableRichEditorServices = enableInteractiveServices && (!tooLargeForEditorServices || forceExpensiveServices); + // Hidden tabs keep their rich view options so switching tabs does not + // rebuild editor parts; their providers skip requests while hidden. The + // size flag describes the focused editor only, so it applies only there. + const enableRichViewOptions = + supportsInteractiveServices && + (!isActiveSurface || !tooLargeForEditorServices || forceExpensiveServices); const enableCodeLens = enableRichEditorServices && codeLensEnabled; useLayoutEffect(() => { @@ -627,8 +643,11 @@ const CodeEditor = ({ paneId, viewStateKey: editorViewKey ?? undefined, isActiveSurface, + isVisibleSurface, isPreviewMode: isPreviewBuffer, enableExpensiveServices: enableRichEditorServices, + enableRichViewOptions, + isContentReady: !isRestorePending, readOnly, scrollable, alwaysConsumeMouseWheel: alwaysConsumeMouseWheel, @@ -651,7 +670,6 @@ const CodeEditor = ({ return ( <> -
{/* Breadcrumbs */} {showToolbar && ( @@ -734,11 +752,14 @@ const CodeEditor = ({ {/* Main editor - absolute positioned to fill container */}
- {isRestorePending ? ( -
+ {/* The loading state covers the editor instead of replacing it, so + the editor surface is kept and reused for the next file. */} + {isRestorePending && ( +
- ) : hasRestoreLoadError ? ( + )} + {hasRestoreLoadError ? (

{t("editor.restoreLoadFailed")}

diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 8d0937a9ed..2409cca7cd 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -123,7 +123,10 @@ import { import type { MarkdownScrollMetrics } from "../markdown/scroll-sync"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; -import { registerMonacoDefinitionLinkGesture } from "../engines/monaco/definition-link"; +import { + registerMonacoDefinitionLinkGesture, + type MonacoDefinitionLinkGesture, +} from "../engines/monaco/definition-link"; import { suppressFindWidgetCloseButtonHover } from "../engines/monaco/find-widget-hover"; import { consumeLocalContentSnapshot, @@ -132,11 +135,12 @@ import { import { clampMonacoHoverWidgets, mutationsContainMonacoHoverWidget, - syncMonacoHoverBounds, } from "../engines/monaco/hover-widgets"; +import { registerMonacoSurface, type MonacoSurfaceRegistration } from "../engines/monaco/surface-visibility"; import { toMonacoLanguageId } from "../engines/monaco/language"; import { ensureMonacoLanguageTokenizer } from "../engines/monaco/language-contributions"; import { acquireMonacoModel } from "../engines/monaco/model-lifecycle"; +import { createMonacoModelPositionResolver } from "../engines/monaco/model-position-resolver"; import { reactivateMonacoModelLanguage } from "../engines/monaco/model-language-activation"; import { getEditorBottomScrollPadding } from "../engines/monaco/scroll-padding"; import { acquireEditorModelSource } from "@lithe/editor/model-source"; @@ -172,6 +176,7 @@ import { } from "../engines/monaco/java-run-markers"; import type { JavaImplementationMarker } from "../lsp/java-navigation-models"; import { toast } from "sonner"; +import { MONACO_INPUT_OPTIONS } from "../engines/monaco/input-options"; import { writeClipboardText } from "@/utils/clipboard"; registerMonacoLspProviders(); @@ -185,6 +190,14 @@ interface GitGutterRenderContext { } /** Binds the long-lived gutter controller to the editor's latest render values. */ +/** + * Monaco never unregisters `addCommand` handlers, so a closure from the editor + * setup would keep the disposed editor and its DOM alive for the whole session. + */ +function createSelectAllCommand(callbacks: { current: { selectEntireModel: () => void } }) { + return () => callbacks.current.selectEntireModel(); +} + function gitGutterControllerOptions( context: () => GitGutterRenderContext | null, ): GitGutterControllerOptions { @@ -321,13 +334,37 @@ export interface MonacoEditorScrollApi { setScrollTop: (scrollTop: number) => void; } +/** The file a reused editor currently shows; editor-level handlers read it. */ +interface MonacoModelBinding { + model: Monaco.editor.ITextModel; + filePath: string; + editorBufferId: string; + paneId: string | undefined; + viewKey: string; +} + export interface MonacoEditorProps { bufferId?: string; paneId?: string; viewStateKey?: string; isActiveSurface?: boolean; + /** Shown in its pane, focused or not; defaults to `isActiveSurface`. */ + isVisibleSurface?: boolean; isPreviewMode?: boolean; enableExpensiveServices?: boolean; + /** + * Whether this editor may show rich view options (code lens, glyph margin, + * parameter hints) at all, independent of focus. Defaults to + * `enableExpensiveServices`. Hosts that keep hidden editors mounted pass a + * focus-independent value: toggling these options rebuilds editor parts and + * restyles the whole editor, which made every tab switch pay that cost twice. + */ + enableRichViewOptions?: boolean; + /** + * False while the buffer's text is still loading. The editor stays mounted so + * it can be reused, and the file is bound only once its text has arrived. + */ + isContentReady?: boolean; readOnly?: boolean; scrollable?: boolean; alwaysConsumeMouseWheel?: boolean; @@ -368,8 +405,11 @@ export function MonacoEditor({ paneId, viewStateKey, isActiveSurface = true, + isVisibleSurface = isActiveSurface, isPreviewMode = false, enableExpensiveServices = true, + enableRichViewOptions = enableExpensiveServices, + isContentReady = true, readOnly = false, scrollable = true, alwaysConsumeMouseWheel = true, @@ -433,6 +473,8 @@ export function MonacoEditor({ // editor — and re-tokenizing the file from scratch — on each switch. Option // changes are applied by the dedicated `updateOptions` effect instead. const enableExpensiveServicesRef = useRef(enableExpensiveServices); + const enableRichViewOptionsRef = useRef(enableRichViewOptions); + const surfaceRegistrationRef = useRef(null); const activeBufferId = useBufferStore((state) => propBufferId ?? state.activeBufferId); const buffer = useBufferStore( useCallback( @@ -697,6 +739,7 @@ export function MonacoEditor({ onScrollMetricsChangeRef.current = onScrollMetricsChange; onEditorScrollApiReadyRef.current = onEditorScrollApiReady; enableExpensiveServicesRef.current = enableExpensiveServices; + enableRichViewOptionsRef.current = enableRichViewOptions; const isCurrentEditorSurface = useCallback(() => { if (!isActiveSurfaceRef.current) return false; @@ -708,6 +751,21 @@ export function MonacoEditor({ return !viewStateKey || !activeViewKey || activeViewKey === viewStateKey; }, [editorBufferId, viewStateKey]); + // Lets language providers skip models that only hidden tabs show. + const isVisibleSurfaceRef = useRef(isVisibleSurface); + isVisibleSurfaceRef.current = isVisibleSurface; + useEffect(() => { + const registration = registerMonacoSurface(modelUri.toString(), isVisibleSurfaceRef.current); + surfaceRegistrationRef.current = registration; + return () => { + registration.dispose(); + if (surfaceRegistrationRef.current === registration) surfaceRegistrationRef.current = null; + }; + }, [modelUri]); + useEffect(() => { + surfaceRegistrationRef.current?.setVisible(isVisibleSurface); + }, [isVisibleSurface]); + const lineNumberFormatter = useCallback( (lineNumber: number) => { const mappedLine = lineNumberMap?.[lineNumber - 1]; @@ -1095,179 +1153,121 @@ export function MonacoEditor({ inlineEditState.inlineEditToolbarActions.hide(); }); + // The Monaco editor outlives the file it shows. Creating an editor builds + // its whole DOM and runs every contribution (70-100 ms per open), so the + // editor is created once per surface and file switches only swap the model, + // as VS Code does. Everything bound to one file lives in the model effect + // below; editor-level handlers read the current file through this ref. + const modelBindingRef = useRef(null); + const definitionLinkGestureRef = useRef(null); + const releaseBoundModelRef = useRef<(() => void) | null>(null); + const editorCallbacksRef = useRef({ + isCurrentEditorSurface, + onScrollOffsetChange, + selectEntireModel, + setContextMenuPosition, + setScrollForBuffer, + setViewportHeight, + syncCursorAndSelection, + }); + editorCallbacksRef.current = { + isCurrentEditorSurface, + onScrollOffsetChange, + selectEntireModel, + setContextMenuPosition, + setScrollForBuffer, + setViewportHeight, + syncCursorAndSelection, + }; + // Creation-time options; later changes go through the `updateOptions` effect. + const creationOptionsRef = useRef<() => Monaco.editor.IStandaloneEditorConstructionOptions>( + () => ({}), + ); + creationOptionsRef.current = () => ({ + ...MONACO_INPUT_OPTIONS, + automaticLayout: true, + fontFamily, + fontSize, + lineHeight, + tabSize, + insertSpaces: true, + readOnly: readOnly || isPreviewMode, + domReadOnly: readOnly || isPreviewMode, + minimap: { enabled: minimapEnabled }, + fontLigatures: editorFontLigatures, + // Geist Mono is a variable font; Windows DPI plus Monaco's default + // monospace width cache places the caret one column left of the click. + disableMonospaceOptimizations: true, + selectOnLineNumbers: true, + glyphMargin: enableRichViewOptions && monacoLanguageId === "java", + stickyScroll: { enabled: editorStickyScroll }, + bracketPairColorization: { enabled: editorBracketPairColorization }, + smoothScrolling: editorSmoothScrolling, + mouseWheelScrollSensitivity: EDITOR_CONSTANTS.MOUSE_WHEEL_SCROLL_SENSITIVITY, + scrollBeyondLastLine: editorScrollBeyondLastLine, + // Annotate owns the column while it is on, the way the macOS workbench + // keeps numbers visible so the annotation has something to sit next to. + lineNumbers: lineNumbers || blameGutterEntriesRef.current.size > 0 ? lineNumberFormatter : "off", + lineNumbersMinChars: + blameGutterEntriesRef.current.size > 0 + ? blameColumnCharsRef.current + : DEFAULT_LINE_NUMBER_CHARS, + renderWhitespace: renderWhitespace === "none" ? "none" : renderWhitespace, + wordWrap: wordWrap ? "on" : "off", + guides: { + indentation: renderIndentGuides, + highlightActiveIndentation: renderIndentGuides, + }, + occurrencesHighlight: highlightOccurrences ? "singleFile" : "off", + selectionHighlight: highlightOccurrences, + quickSuggestions: autoCompletion, + suggestOnTriggerCharacters: autoCompletion, + // Word-scan suggestions are a fallback when LSP completions are unavailable. + // They insert plain identifiers without namespace imports, so LSP results + // remain the primary source for typed class completions. + wordBasedSuggestions: "matchingDocuments", + parameterHints: { enabled: enableRichViewOptions && parameterHints }, + codeLens: enableRichViewOptions && codeLens, + inlayHints: { enabled: enableRichViewOptions && inlayHints ? "on" : "off" }, + theme: defineMonacoTheme(themeId, editorItalicComments), + cursorStyle: vimModeEnabled && vimCurrentMode === "normal" ? "block" : editorCursorStyle, + cursorBlinking: vimModeEnabled && vimCurrentMode === "normal" ? "solid" : editorCursorBlinking, + contextmenu: false, + overviewRulerLanes: 0, + fixedOverflowWidgets: false, + // Full-width punctuation is normal Chinese prose; see frontend/editor workbench.ts + // for the rationale behind disabling Monaco's confusable-character warning. + unicodeHighlight: { ambiguousCharacters: false }, + // Standalone Monaco stores every editor's options in one shared + // configuration, so this flag is global: it follows the setting alone and + // the provider decides which models get tokens. + "semanticHighlighting.enabled": semanticTokens, + scrollbar: { + vertical: scrollable ? "auto" : "hidden", + horizontal: scrollable ? "auto" : "hidden", + handleMouseWheel: scrollable, + alwaysConsumeMouseWheel: scrollable && alwaysConsumeMouseWheel, + verticalScrollbarSize: EDITOR_CONSTANTS.SCROLLBAR_TRACK_SIZE, + horizontalScrollbarSize: EDITOR_CONSTANTS.SCROLLBAR_TRACK_SIZE, + }, + }); + useLayoutEffect(() => { const container = containerRef.current; - if (!container || !editorBufferId) return; - const finishCreatedRestore = viewStateRestoreGate.begin(); - const fontOptions = { fontFamily, fontSize, lineHeight }; - syncMonacoHoverBounds(container); - if (filePath && fileOpenBenchmark.has(filePath)) { - fileOpenBenchmark.mark(filePath, "monaco-create-start"); - } - const languageTokenizerPromise = ensureMonacoLanguageTokenizer(monacoLanguageId).catch( - (error) => { - console.error(`Failed to load Monaco tokenizer for ${monacoLanguageId}:`, error); - return false; - }, - ); - - const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri); - const model = acquiredModel.model; - const source = acquireEditorModelSource(model, content); + if (!container) return; + const callbacks = () => editorCallbacksRef.current; const editor = monacoEditor.create(container, { - model, - automaticLayout: true, - fontFamily, - fontSize, - lineHeight, - tabSize, - insertSpaces: true, - readOnly: readOnly || isPreviewMode, - domReadOnly: readOnly || isPreviewMode, - minimap: { enabled: minimapEnabled }, - fontLigatures: editorFontLigatures, - // Geist Mono is a variable font; Windows DPI plus Monaco's default - // monospace width cache places the caret one column left of the click. - disableMonospaceOptimizations: true, - selectOnLineNumbers: true, - glyphMargin: enableExpensiveServicesRef.current && monacoLanguageId === "java", - stickyScroll: { enabled: editorStickyScroll }, - bracketPairColorization: { enabled: editorBracketPairColorization }, - smoothScrolling: editorSmoothScrolling, - mouseWheelScrollSensitivity: EDITOR_CONSTANTS.MOUSE_WHEEL_SCROLL_SENSITIVITY, - scrollBeyondLastLine: editorScrollBeyondLastLine, + ...creationOptionsRef.current(), + model: null, padding: { bottom: getEditorBottomScrollPadding(container.clientHeight) }, - // Annotate owns the column while it is on, the way the macOS workbench - // keeps numbers visible so the annotation has something to sit next to. - lineNumbers: - lineNumbers || blameGutterEntriesRef.current.size > 0 - ? lineNumberFormatter - : "off", - lineNumbersMinChars: - blameGutterEntriesRef.current.size > 0 - ? blameColumnCharsRef.current - : DEFAULT_LINE_NUMBER_CHARS, - renderWhitespace: renderWhitespace === "none" ? "none" : renderWhitespace, - wordWrap: wordWrap ? "on" : "off", - guides: { - indentation: renderIndentGuides, - highlightActiveIndentation: renderIndentGuides, - }, - occurrencesHighlight: highlightOccurrences ? "singleFile" : "off", - selectionHighlight: highlightOccurrences, - quickSuggestions: autoCompletion, - suggestOnTriggerCharacters: autoCompletion, - // Word-scan suggestions are a fallback when LSP completions are unavailable. - // They insert plain identifiers without namespace imports, so LSP results - // remain the primary source for typed class completions. - wordBasedSuggestions: "matchingDocuments", - // Expensive-service options are read from the ref so this effect stays - // stable across active-surface flips; the dedicated `updateOptions` effect - // below re-applies them whenever the live value changes. - parameterHints: { enabled: enableExpensiveServicesRef.current && parameterHints }, - codeLens: enableExpensiveServicesRef.current && codeLens, - inlayHints: { enabled: enableExpensiveServicesRef.current && inlayHints ? "on" : "off" }, - theme: defineMonacoTheme(themeId, editorItalicComments), - cursorStyle: vimModeEnabled && vimCurrentMode === "normal" ? "block" : editorCursorStyle, - cursorBlinking: - vimModeEnabled && vimCurrentMode === "normal" ? "solid" : editorCursorBlinking, - contextmenu: false, - overviewRulerLanes: 0, - fixedOverflowWidgets: false, - // Full-width punctuation is normal Chinese prose; see frontend/editor workbench.ts - // for the rationale behind disabling Monaco's confusable-character warning. - unicodeHighlight: { ambiguousCharacters: false }, - "semanticHighlighting.enabled": enableExpensiveServicesRef.current && semanticTokens, - scrollbar: { - vertical: scrollable ? "auto" : "hidden", - horizontal: scrollable ? "auto" : "hidden", - handleMouseWheel: scrollable, - alwaysConsumeMouseWheel: scrollable && alwaysConsumeMouseWheel, - verticalScrollbarSize: EDITOR_CONSTANTS.SCROLLBAR_TRACK_SIZE, - horizontalScrollbarSize: EDITOR_CONSTANTS.SCROLLBAR_TRACK_SIZE, - }, }); const restoreFindWidgetCloseButtonHover = suppressFindWidgetCloseButtonHover(container); editorRef.current = editor; - modelRef.current = model; const implementationDecorations = editor.createDecorationsCollection(); implementationDecorationsRef.current = implementationDecorations; - const runMarkerDecorationCollection = editor.createDecorationsCollection( - runMarkerDecorationSpecsRef.current, - ); + const runMarkerDecorationCollection = editor.createDecorationsCollection(); runMarkerDecorationsRef.current = runMarkerDecorationCollection; - // The gutter controller lives exactly as long as this editor and model, - // so recreating the editor (file switch, option change) also disposes any - // open inline review. Its base arrives through `setBase`. - const gitGutterController = createGitGutterController( - editor, - model, - gitGutterControllerOptions(() => gitGutterContextRef.current), - ); - gitGutterControllerRef.current = gitGutterController; - gitGutterController.setBase(gitGutterContextRef.current?.base?.lines ?? null); - previousContentRef.current = content; - pendingLocalContentSnapshotsRef.current = []; - if (filePath && fileOpenBenchmark.has(filePath)) { - fileOpenBenchmark.mark(filePath, "monaco-created", `${model.getLineCount()} lines`); - } - let benchmarkRafId: number | null = null; - let benchmarkTimeoutId: number | null = null; - let benchmarkFinished = false; - const getBenchmarkTokenTypes = () => - Array.from( - new Set( - monacoEditor - .tokenize(content.slice(0, 4_096), model.getLanguageId()) - .flatMap((line) => line.map((token) => token.type)) - .filter(Boolean), - ), - ).slice(0, 12); - const finishBenchmark = () => { - if (benchmarkFinished) return; - benchmarkFinished = true; - if (benchmarkRafId !== null) cancelAnimationFrame(benchmarkRafId); - if (benchmarkTimeoutId !== null) window.clearTimeout(benchmarkTimeoutId); - if (!filePath || !fileOpenBenchmark.has(filePath) || model.isDisposed()) return; - const benchmarkTokenTypes = getBenchmarkTokenTypes(); - fileOpenBenchmark.finish(filePath, "editor-ready", `${content.length} chars`, { - contentLength: content.length, - lineCount: model.getLineCount(), - largeContentMode: false, - languageId: model.getLanguageId(), - themeId, - tokenTypes: benchmarkTokenTypes, - }); - recordStartupMilestone("editor:first-ready"); - }; - void languageTokenizerPromise.then((loaded) => { - if (model.isDisposed()) return; - if (loaded) { - reactivateMonacoModelLanguage(model, monacoLanguageId, (target, language) => { - monacoEditor.setModelLanguage(target as Monaco.editor.ITextModel, language); - }); - } - const tokenTypes = getBenchmarkTokenTypes(); - frontendTrace(tokenTypes.length > 0 ? "info" : "error", "bench:syntax", filePath, { - languageId: model.getLanguageId(), - themeId, - tokenTypes, - }); - if (filePath && fileOpenBenchmark.has(filePath)) { - fileOpenBenchmark.mark( - filePath, - "syntax-ready", - loaded ? model.getLanguageId() : "built-in", - ); - } - if (document.visibilityState === "visible") { - benchmarkRafId = requestAnimationFrame(finishBenchmark); - benchmarkTimeoutId = window.setTimeout(finishBenchmark, 100); - } else { - benchmarkTimeoutId = window.setTimeout(finishBenchmark, 0); - } - }); let hoverClampRaf: number | null = null; const scheduleMonacoHoverClamp = () => { @@ -1296,12 +1296,21 @@ export function MonacoEditor({ editor.updateOptions({ padding: { bottom: bottomScrollPadding } }); }; - const syncNestedEditorFonts = () => syncContainedEditorFontOptions(container, fontOptions); + const currentFontOptions = () => { + const options = editor.getRawOptions(); + return { + fontFamily: options.fontFamily, + fontSize: options.fontSize, + lineHeight: options.lineHeight, + }; + }; + const syncNestedEditorFonts = () => + syncContainedEditorFontOptions(container, currentFontOptions()); const createdEditorDisposable = monacoEditor.onDidCreateEditor((createdEditor) => { requestAnimationFrame(() => { const editorElement = createdEditor.getDomNode(); if (!editorElement || !container.contains(editorElement)) return; - createdEditor.updateOptions(fontOptions); + createdEditor.updateOptions(currentFontOptions()); }); }); requestAnimationFrame(syncNestedEditorFonts); @@ -1318,24 +1327,18 @@ export function MonacoEditor({ }; onEditorScrollApiReadyRef.current?.(scrollApi); - editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyA, selectEntireModel); - const definitionLinkGesture = registerMonacoDefinitionLinkGesture({ - editor, - model, - documentTarget, - workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined, - isEnabled: () => enableExpensiveServicesRef.current, - }); + editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyA, createSelectAllCommand(editorCallbacksRef)); let definitionClickIntent = 0; const cursorEntryFromEditor = (): CursorHistoryEntry | null => { + const binding = modelBindingRef.current; const position = editor.getPosition(); - if (!position || !editorBufferId || !filePath) return null; + if (!binding || !position || !binding.editorBufferId || !binding.filePath) return null; return { - bufferId: editorBufferId, - filePath, - paneId, - ...toEditorPosition(model, position), + bufferId: binding.editorBufferId, + filePath: binding.filePath, + paneId: binding.paneId, + ...toEditorPosition(binding.model, position), scrollTop: editor.getScrollTop(), scrollLeft: editor.getScrollLeft(), }; @@ -1374,13 +1377,12 @@ export function MonacoEditor({ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - selectEntireModel(); + callbacks().selectEntireModel(); }; window.addEventListener("keydown", handleWindowSelectAllShortcut, true); const disposables = [ - bindMonacoDebugDecorations(editor, filePath), editor.onContextMenu((event) => { event.event.preventDefault(); event.event.stopPropagation(); @@ -1389,10 +1391,6 @@ export function MonacoEditor({ // "this line" from both, and a menu item that silently no-ops on // content right-clicks reads as broken. The range is computed from the // pre-collapse selection, so content clicks may still move the caret. - // Line targets cover the gutter and the content itself: IDEA answers - // "this line" from both, and a menu item that silently no-ops on - // content right-clicks reads as broken. The range is computed from the - // pre-collapse selection, so content clicks may still move the caret. // An open selection wins wherever the click landed; without one the // clicked line is the query. const gutterTypes = [ @@ -1456,12 +1454,12 @@ export function MonacoEditor({ event.target.position.column, ), ); - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); } } editor.focus(); - setContextMenuPosition({ + callbacks().setContextMenuPosition({ x: event.event.posx, y: event.event.posy, lineNumber: event.target.position?.lineNumber ?? editor.getPosition()?.lineNumber ?? 0, @@ -1480,51 +1478,12 @@ export function MonacoEditor({ event.preventDefault(); event.stopPropagation(); - selectEntireModel(); - }), - // Listen on the model, not the editor: Monaco delivers the editor-level - // content event before EditorModelSource's model subscription on the - // undo/redo path, so reading `source.lastChange` here published a stale - // document (undo looked like a no-op and the save pipeline skipped the - // write). Model listeners run in registration order and this one is - // registered after the source acquired above, so `lastChange` is fresh. - model.onDidChangeContent(() => { - // Monaco moves decorations before React publishes the new document. - // Invalidate click/context targets synchronously, not just on rerender. - javaRunMarkersRef.current = []; - runMarkerDecorationCollection.clear(); - setRunMarkerMenu(null); - setContextMenuRunMarker(null); - if (applyingExternalChangeRef.current) return; - const change = source.lastChange; - if (!change || change.external) return; - const previousContent = change.previousContent; - const nextContent = change.content; - const contentChanges: EditorTextChange[] = change.changes.map(edit => ({ - rangeOffset: edit.offset, rangeLength: edit.length, text: edit.text, - startLine: edit.start.line, startColumn: edit.start.column, - endLine: edit.end.line, endColumn: edit.end.column, - })); - const editorState = useEditorStateStore.getState(); - previousContentRef.current = nextContent; - rememberLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, nextContent); - if (filePath && enableExpensiveServicesRef.current) { - queueLspDocumentChanges(filePath, contentChanges); - } - latestContentChangeRef.current?.( - nextContent, - previousContent, - editorState.cursorPosition, - editorState.selection, - { - contentChange: contentChanges[0], - contentChanges, - }, - ); - syncCursorAndSelection(); + callbacks().selectEntireModel(); }), editor.onMouseDown((event) => { - if (!isCurrentEditorSurface()) return; + if (!callbacks().isCurrentEditorSurface()) return; + const binding = modelBindingRef.current; + if (!binding) return; const mouseEvent = event.event; const markerElement = event.target.element; // IDEA opens the annotated commit from the gutter label itself; the @@ -1536,7 +1495,7 @@ export function MonacoEditor({ mouseEvent.stopPropagation(); mouseSelectingRef.current = false; mouseGestureStartRef.current = null; - setContextMenuPosition(null); + callbacks().setContextMenuPosition(null); revealBlameCommitRef.current(lineNumber); return; } @@ -1557,7 +1516,7 @@ export function MonacoEditor({ mouseEvent.stopPropagation(); mouseSelectingRef.current = false; mouseGestureStartRef.current = null; - setContextMenuPosition(null); + callbacks().setContextMenuPosition(null); setRunMarkerMenu({ marker, position: { x: mouseEvent.posx, y: mouseEvent.posy } }); return; } @@ -1581,7 +1540,7 @@ export function MonacoEditor({ lineNumber: marker.line + 1, column: marker.utf16Column + 1, }); - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); void keymapRegistry.executeCommand( marker.direction === "down" ? "editor.goToImplementation" @@ -1592,7 +1551,7 @@ export function MonacoEditor({ } } if ( - filePath.toLowerCase().endsWith(".java") && + binding.filePath.toLowerCase().endsWith(".java") && !editor.getRawOptions().readOnly && event.target.type === monacoEditor.MouseTargetType.GUTTER_GLYPH_MARGIN && event.target.position && @@ -1602,7 +1561,7 @@ export function MonacoEditor({ mouseEvent.stopPropagation(); useDebuggerStore .getState() - .actions.toggleBreakpoint(filePath, event.target.position.lineNumber - 1); + .actions.toggleBreakpoint(binding.filePath, event.target.position.lineNumber - 1); return; } if ( @@ -1615,18 +1574,21 @@ export function MonacoEditor({ mouseSelectingRef.current = false; mouseGestureStartRef.current = null; editor.setPosition(event.target.position); - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); const clickedPosition = event.target.position; const clickIntent = ++definitionClickIntent; - if (!definitionLinkGesture.enabled) { + const definitionLinkGesture = definitionLinkGestureRef.current; + if (!definitionLinkGesture?.enabled) { void keymapRegistry.executeCommand("editor.goToDefinition"); return; } + const clickedModel = binding.model; void definitionLinkGesture.resolveForClick(clickedPosition).then((definitionHint) => { if ( clickIntent !== definitionClickIntent || !definitionHint || - model.isDisposed() || + clickedModel.isDisposed() || + editor.getModel() !== clickedModel || !isActiveSurfaceRef.current ) { return; @@ -1653,7 +1615,7 @@ export function MonacoEditor({ editor.onMouseUp(() => { if (!mouseSelectingRef.current) return; mouseSelectingRef.current = false; - if (!isCurrentEditorSurface()) { + if (!callbacks().isCurrentEditorSurface()) { mouseGestureStartRef.current = null; return; } @@ -1665,20 +1627,21 @@ export function MonacoEditor({ mouseGestureStartRef.current = null; }), editor.onDidChangeCursorSelection(() => { - if (!isCurrentEditorSurface()) return; + if (!modelBindingRef.current) return; + if (!callbacks().isCurrentEditorSurface()) return; if (suppressNextCursorSelectionSyncRef.current) { suppressNextCursorSelectionSyncRef.current = false; - return; + return; } - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); }), - definitionLinkGesture, editor.onDidScrollChange((event) => { - if (!isCurrentEditorSurface()) return; + const binding = modelBindingRef.current; + if (!binding) return; + if (!callbacks().isCurrentEditorSurface()) return; if (viewStateRestoreGate.isRestoring()) return; - const viewKey = viewStateKey ?? activeBufferId ?? null; - setScrollForBuffer(viewKey, event.scrollTop, event.scrollLeft); - onScrollOffsetChange?.(event.scrollTop, event.scrollLeft); + callbacks().setScrollForBuffer(binding.viewKey || null, event.scrollTop, event.scrollLeft); + callbacks().onScrollOffsetChange?.(event.scrollTop, event.scrollLeft); onScrollMetricsChangeRef.current?.({ scrollTop: event.scrollTop, scrollHeight: event.scrollHeight, @@ -1686,18 +1649,19 @@ export function MonacoEditor({ }); }), editor.onDidLayoutChange((info) => { - setViewportHeight(info.height); + callbacks().setViewportHeight(info.height); syncBottomScrollPadding(info.height); scheduleMonacoHoverClamp(); - if (!viewStateRestoreGate.isRestoring()) return; - applyMonacoSurfaceViewport(editor, viewStateKey ?? activeBufferId ?? ""); + const binding = modelBindingRef.current; + if (!binding || !viewStateRestoreGate.isRestoring()) return; + applyMonacoSurfaceViewport(editor, binding.viewKey); }), ]; const handleWindowMouseUp = () => { if (!mouseSelectingRef.current) return; mouseSelectingRef.current = false; - if (!isCurrentEditorSurface()) { + if (!callbacks().isCurrentEditorSurface()) { mouseGestureStartRef.current = null; return; } @@ -1710,23 +1674,229 @@ export function MonacoEditor({ }; window.addEventListener("mouseup", handleWindowMouseUp); - const unsubscribeCursor = editorAPI.on("cursorChange", (position) => { - if (!modelRef.current || editorRef.current !== editor) return; - if (!isCurrentEditorSurface() || mouseSelectingRef.current) return; - const monacoPosition = toClampedMonacoPosition(model, position); - const currentPosition = editor.getPosition(); - if ( - currentPosition && - currentPosition.lineNumber === monacoPosition.lineNumber && - currentPosition.column === monacoPosition.column - ) { - editor.focus(); - return; + return () => { + // React runs this cleanup before the model effect's on unmount, but the + // model teardown saves the viewport and needs a live editor. + releaseBoundModelRef.current?.(); + onModelPositionResolverChange?.(null); + container.removeEventListener("mousedown", handleNativeMouseDownCapture, true); + restoreFindWidgetCloseButtonHover(); + window.removeEventListener("keydown", handleWindowSelectAllShortcut, true); + window.removeEventListener("mouseup", handleWindowMouseUp); + for (const disposable of disposables) { + disposable.dispose(); } - const currentSelection = editor.getSelection(); - if (currentSelection && !currentSelection.isEmpty()) { - editor.revealPositionInCenterIfOutsideViewport(monacoPosition); - editor.focus(); + hoverMutationObserver.disconnect(); + hoverResizeObserver.disconnect(); + if (hoverClampRaf !== null) { + cancelAnimationFrame(hoverClampRaf); + } + mouseSelectingRef.current = false; + mouseGestureStartRef.current = null; + createdEditorDisposable.dispose(); + onEditorScrollApiReadyRef.current?.(null); + try { + vimAdapterRef.current?.dispose(); + } catch (error) { + console.error("Failed to dispose Monaco Vim adapter:", error); + } + vimAdapterRef.current = null; + vimStatusRef.current?.remove(); + vimStatusRef.current = null; + implementationDecorations.clear(); + if (implementationDecorationsRef.current === implementationDecorations) { + implementationDecorationsRef.current = null; + } + runMarkerDecorationCollection.clear(); + if (runMarkerDecorationsRef.current === runMarkerDecorationCollection) { + runMarkerDecorationsRef.current = null; + } + if (editorRef.current === editor) editorRef.current = null; + try { + editor.dispose(); + } catch (error) { + console.error("Failed to dispose Monaco editor:", error); + } + }; + // The editor is created once per surface; option changes are applied by + // the `updateOptions` effect and file switches by the model effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useLayoutEffect(() => { + const editor = editorRef.current; + const container = containerRef.current; + if (!editor || !container || !editorBufferId || !isContentReady) return; + const finishCreatedRestore = viewStateRestoreGate.begin(); + if (filePath && fileOpenBenchmark.has(filePath)) { + fileOpenBenchmark.mark(filePath, "monaco-create-start"); + } + const languageTokenizerPromise = ensureMonacoLanguageTokenizer(monacoLanguageId).catch( + (error) => { + console.error(`Failed to load Monaco tokenizer for ${monacoLanguageId}:`, error); + return false; + }, + ); + + const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri); + const model = acquiredModel.model; + const source = acquireEditorModelSource(model, content); + editor.setModel(model); + const viewKey = viewStateKey ?? activeBufferId ?? ""; + modelRef.current = model; + modelBindingRef.current = { + model, + filePath, + editorBufferId, + paneId, + viewKey, + }; + // The gutter controller lives exactly as long as this model is shown, so a + // file switch also disposes any open inline review. Its base arrives + // through `setBase`. + const gitGutterController = createGitGutterController( + editor, + model, + gitGutterControllerOptions(() => gitGutterContextRef.current), + ); + gitGutterControllerRef.current = gitGutterController; + gitGutterController.setBase(gitGutterContextRef.current?.base?.lines ?? null); + previousContentRef.current = content; + pendingLocalContentSnapshotsRef.current = []; + if (filePath && fileOpenBenchmark.has(filePath)) { + fileOpenBenchmark.mark(filePath, "monaco-created", `${model.getLineCount()} lines`); + } + let benchmarkRafId: number | null = null; + let benchmarkTimeoutId: number | null = null; + let benchmarkFinished = false; + const getBenchmarkTokenTypes = () => + Array.from( + new Set( + monacoEditor + .tokenize(content.slice(0, 4_096), model.getLanguageId()) + .flatMap((line) => line.map((token) => token.type)) + .filter(Boolean), + ), + ).slice(0, 12); + const finishBenchmark = () => { + if (benchmarkFinished) return; + benchmarkFinished = true; + if (benchmarkRafId !== null) cancelAnimationFrame(benchmarkRafId); + if (benchmarkTimeoutId !== null) window.clearTimeout(benchmarkTimeoutId); + if (!filePath || !fileOpenBenchmark.has(filePath) || model.isDisposed()) return; + const benchmarkTokenTypes = getBenchmarkTokenTypes(); + fileOpenBenchmark.finish(filePath, "editor-ready", `${content.length} chars`, { + contentLength: content.length, + lineCount: model.getLineCount(), + largeContentMode: false, + languageId: model.getLanguageId(), + themeId, + tokenTypes: benchmarkTokenTypes, + }); + recordStartupMilestone("editor:first-ready"); + }; + void languageTokenizerPromise.then((loaded) => { + if (model.isDisposed()) return; + if (loaded) { + reactivateMonacoModelLanguage(model, monacoLanguageId, (target, language) => { + monacoEditor.setModelLanguage(target as Monaco.editor.ITextModel, language); + }); + } + if (filePath && fileOpenBenchmark.has(filePath)) { + // Re-tokenizing the file head is benchmark-only work; ordinary opens + // skip it so it does not add to every tab switch. + const tokenTypes = getBenchmarkTokenTypes(); + frontendTrace(tokenTypes.length > 0 ? "info" : "error", "bench:syntax", filePath, { + languageId: model.getLanguageId(), + themeId, + tokenTypes, + }); + fileOpenBenchmark.mark( + filePath, + "syntax-ready", + loaded ? model.getLanguageId() : "built-in", + ); + } + if (document.visibilityState === "visible") { + benchmarkRafId = requestAnimationFrame(finishBenchmark); + benchmarkTimeoutId = window.setTimeout(finishBenchmark, 100); + } else { + benchmarkTimeoutId = window.setTimeout(finishBenchmark, 0); + } + }); + + const definitionLinkGesture = registerMonacoDefinitionLinkGesture({ + editor, + model, + documentTarget, + workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined, + isEnabled: () => enableExpensiveServicesRef.current, + }); + definitionLinkGestureRef.current = definitionLinkGesture; + + const disposables = [ + bindMonacoDebugDecorations(editor, filePath), + definitionLinkGesture, + // Listen on the model, not the editor: Monaco delivers the editor-level + // content event before EditorModelSource's model subscription on the + // undo/redo path, so reading `source.lastChange` here published a stale + // document (undo looked like a no-op and the save pipeline skipped the + // write). Model listeners run in registration order and this one is + // registered after the source acquired above, so `lastChange` is fresh. + model.onDidChangeContent(() => { + // Monaco moves decorations before React publishes the new document. + // Invalidate click/context targets synchronously, not just on rerender. + javaRunMarkersRef.current = []; + runMarkerDecorationsRef.current?.clear(); + setRunMarkerMenu(null); + setContextMenuRunMarker(null); + if (applyingExternalChangeRef.current) return; + const change = source.lastChange; + if (!change || change.external) return; + const previousContent = change.previousContent; + const nextContent = change.content; + const contentChanges: EditorTextChange[] = change.changes.map(edit => ({ + rangeOffset: edit.offset, rangeLength: edit.length, text: edit.text, + startLine: edit.start.line, startColumn: edit.start.column, + endLine: edit.end.line, endColumn: edit.end.column, + })); + const editorState = useEditorStateStore.getState(); + previousContentRef.current = nextContent; + rememberLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, nextContent); + if (filePath && enableExpensiveServicesRef.current) { + queueLspDocumentChanges(filePath, contentChanges); + } + latestContentChangeRef.current?.( + nextContent, + previousContent, + editorState.cursorPosition, + editorState.selection, + { + contentChange: contentChanges[0], + contentChanges, + }, + ); + editorCallbacksRef.current.syncCursorAndSelection(); + }), + ]; + + const unsubscribeCursor = editorAPI.on("cursorChange", (position) => { + if (modelRef.current !== model || editorRef.current !== editor) return; + if (!isCurrentEditorSurface() || mouseSelectingRef.current) return; + const monacoPosition = toClampedMonacoPosition(model, position); + const currentPosition = editor.getPosition(); + if ( + currentPosition && + currentPosition.lineNumber === monacoPosition.lineNumber && + currentPosition.column === monacoPosition.column + ) { + editor.focus(); + return; + } + const currentSelection = editor.getSelection(); + if (currentSelection && !currentSelection.isEmpty()) { + editor.revealPositionInCenterIfOutsideViewport(monacoPosition); + editor.focus(); return; } editor.setPosition(monacoPosition); @@ -1734,7 +1904,7 @@ export function MonacoEditor({ editor.focus(); }); const unsubscribeSelection = editorAPI.on("selectionChange", (selection) => { - if (!modelRef.current || editorRef.current !== editor) return; + if (modelRef.current !== model || editorRef.current !== editor) return; if (!isCurrentEditorSurface() || mouseSelectingRef.current) return; if (selection) { editor.setSelection(toMonacoRange(model, selection)); @@ -1753,22 +1923,31 @@ export function MonacoEditor({ } }); - const createdViewKey = viewStateKey ?? activeBufferId ?? ""; - const createdNavigationRevision = editorAPI.getOwnerNavigationRevision(createdViewKey); - applyMonacoSurfaceViewState(editor, createdViewKey); + const createdNavigationRevision = editorAPI.getOwnerNavigationRevision(viewKey); + applyMonacoSurfaceViewState(editor, viewKey); const cancelCreatedViewStateRestore = scheduleCachedViewStateRestore({ editor, - cachedScroll: cachedScrollForViewKey(createdViewKey), - isEditorCurrent: () => editorRef.current === editor, + cachedScroll: cachedScrollForViewKey(viewKey), + isEditorCurrent: () => editorRef.current === editor && editor.getModel() === model, isNavigationRevisionCurrent: () => - editorAPI.getOwnerNavigationRevision(createdViewKey) === createdNavigationRevision, + editorAPI.getOwnerNavigationRevision(viewKey) === createdNavigationRevision, focus: () => { if (isActiveSurfaceRef.current) editor.focus(); }, onRestoreComplete: finishCreatedRestore, }); - return () => { + let released = false; + const releaseBoundModel = () => { + if (released) return; + released = true; + if (releaseBoundModelRef.current === releaseBoundModel) releaseBoundModelRef.current = null; + // Menus opened on this file must not survive a switch to another one. + setRunMarkerMenu(null); + setContextMenuRunMarker(null); + setGutterMenuPosition(null); + setLineHistoryMenu(null); + editorCallbacksRef.current.setContextMenuPosition(null); cancelCreatedViewStateRestore(); finishCreatedRestore(); if (benchmarkRafId !== null) cancelAnimationFrame(benchmarkRafId); @@ -1776,54 +1955,34 @@ export function MonacoEditor({ if (filePath && fileOpenBenchmark.has(filePath)) { fileOpenBenchmark.cancel(filePath, "editor-unmounted-before-ready"); } - onModelPositionResolverChange?.(null); unsubscribeCursor(); unsubscribeSelection(); - container.removeEventListener("mousedown", handleNativeMouseDownCapture, true); - restoreFindWidgetCloseButtonHover(); - window.removeEventListener("keydown", handleWindowSelectAllShortcut, true); - window.removeEventListener("mouseup", handleWindowMouseUp); for (const disposable of disposables) { disposable.dispose(); } - hoverMutationObserver.disconnect(); - hoverResizeObserver.disconnect(); - if (hoverClampRaf !== null) { - cancelAnimationFrame(hoverClampRaf); - } - mouseSelectingRef.current = false; - mouseGestureStartRef.current = null; - createdEditorDisposable.dispose(); - onEditorScrollApiReadyRef.current?.(null); - try { - vimAdapterRef.current?.dispose(); - } catch (error) { - console.error("Failed to dispose Monaco Vim adapter:", error); - } - vimAdapterRef.current = null; - vimStatusRef.current?.remove(); - vimStatusRef.current = null; - implementationDecorations.clear(); - if (implementationDecorationsRef.current === implementationDecorations) { - implementationDecorationsRef.current = null; + if (definitionLinkGestureRef.current === definitionLinkGesture) { + definitionLinkGestureRef.current = null; } + implementationDecorationsRef.current?.clear(); implementationMarkerOwnerRef.current = null; implementationMarkersRef.current = []; gitGutterController.dispose(); if (gitGutterControllerRef.current === gitGutterController) { gitGutterControllerRef.current = null; } - runMarkerDecorationCollection.clear(); - if (runMarkerDecorationsRef.current === runMarkerDecorationCollection) { - runMarkerDecorationsRef.current = null; + runMarkerDecorationsRef.current?.clear(); + // Save the viewport while the editor still shows this model. + if (editor.getModel() === model) { + persistMonacoSurfaceViewState(editor, viewKey); } - persistMonacoSurfaceViewState(editor, viewStateKey ?? activeBufferId ?? ""); - if (editorRef.current === editor) editorRef.current = null; + if (modelBindingRef.current?.model === model) modelBindingRef.current = null; if (modelRef.current === model) modelRef.current = null; + // Detach before releasing: releasing may dispose the model, and Monaco + // must not keep rendering a disposed model. try { - editor.dispose(); + if (editor.getModel() === model) editor.setModel(null); } catch (error) { - console.error("Failed to dispose Monaco editor:", error); + console.error("Failed to detach Monaco model:", error); } try { acquiredModel.release(); @@ -1831,51 +1990,21 @@ export function MonacoEditor({ console.error("Failed to release Monaco model:", error); } }; + releaseBoundModelRef.current = releaseBoundModel; + return releaseBoundModel; }, [ activeBufferId, - autoCompletion, - editorBracketPairColorization, - editorCursorBlinking, - editorCursorStyle, - editorFontLigatures, - editorItalicComments, - editorScrollBeyondLastLine, - editorSmoothScrolling, - editorStickyScroll, documentTarget, - inlayHints, - setContextMenuPosition, filePath, paneId, - fontFamily, - fontSize, - highlightOccurrences, - isPreviewMode, - lineHeight, - lineNumbers, - lineNumberFormatter, - minimapEnabled, modelUri, monacoLanguageId, - onScrollOffsetChange, - parameterHints, - readOnly, - renderIndentGuides, - renderWhitespace, rootFolderPath, workspaceId, - scrollable, - alwaysConsumeMouseWheel, - selectEntireModel, - semanticTokens, - setScrollForBuffer, - setViewportHeight, - syncCursorAndSelection, - tabSize, themeId, viewStateKey, - wordWrap, editorBufferId, + isContentReady, isCurrentEditorSurface, ]); @@ -2316,7 +2445,7 @@ export function MonacoEditor({ monacoEditor.setModelMarkers(model, "lithe", []); } }; - }, [diagnosticsForFile]); + }, [diagnosticsForFile, isContentReady]); useEffect(() => { const editor = editorRef.current; @@ -2485,20 +2614,20 @@ export function MonacoEditor({ }, occurrencesHighlight: highlightOccurrences ? "singleFile" : "off", selectionHighlight: highlightOccurrences, - glyphMargin: enableExpensiveServices && monacoLanguageId === "java", + glyphMargin: enableRichViewOptions && monacoLanguageId === "java", quickSuggestions: autoCompletion, suggestOnTriggerCharacters: autoCompletion, // Word-scan suggestions are a fallback when LSP completions are unavailable. // They insert plain identifiers without namespace imports, so LSP results // remain the primary source for typed class completions. wordBasedSuggestions: "matchingDocuments", - parameterHints: { enabled: enableExpensiveServices && parameterHints }, - codeLens: enableExpensiveServices && codeLens, - inlayHints: { enabled: enableExpensiveServices && inlayHints ? "on" : "off" }, + parameterHints: { enabled: enableRichViewOptions && parameterHints }, + codeLens: enableRichViewOptions && codeLens, + inlayHints: { enabled: enableRichViewOptions && inlayHints ? "on" : "off" }, cursorStyle: vimModeEnabled && vimCurrentMode === "normal" ? "block" : editorCursorStyle, cursorBlinking: vimModeEnabled && vimCurrentMode === "normal" ? "solid" : editorCursorBlinking, - "semanticHighlighting.enabled": enableExpensiveServices && semanticTokens, + "semanticHighlighting.enabled": semanticTokens, // Full-width punctuation is normal Chinese prose; see frontend/editor workbench.ts // for the rationale behind disabling Monaco's confusable-character warning. unicodeHighlight: { ambiguousCharacters: false }, @@ -2538,7 +2667,7 @@ export function MonacoEditor({ editorScrollBeyondLastLine, editorSmoothScrolling, editorStickyScroll, - enableExpensiveServices, + enableRichViewOptions, fontFamily, fontSize, highlightOccurrences, @@ -2573,7 +2702,9 @@ export function MonacoEditor({ vimStatusRef.current?.remove(); vimStatusRef.current = null; - if (!editor || !container || !vimModeEnabled || readOnly || isPreviewMode) { + // The adapter reads the model as it attaches, and a surface waiting for its + // text has none yet. + if (!editor || !container || !isContentReady || !vimModeEnabled || readOnly || isPreviewMode) { return; } @@ -2606,7 +2737,7 @@ export function MonacoEditor({ statusNode.remove(); if (vimStatusRef.current === statusNode) vimStatusRef.current = null; }; - }, [isPreviewMode, readOnly, vimModeEnabled]); + }, [isContentReady, isPreviewMode, readOnly, vimModeEnabled]); useEffect(() => { const editor = editorRef.current; @@ -2640,52 +2771,9 @@ export function MonacoEditor({ }, [bufferIsDirty, readOnly]); useEffect(() => { - const editor = editorRef.current; - const model = modelRef.current; - if (!editor || !model) { - onModelPositionResolverChange?.(null); - return; - } - - onModelPositionResolverChange?.((line, column) => { - if (model.isDisposed()) return null; - const position = clampMonacoPosition(model, { - lineNumber: line + 1, - column: column + 1, - }); - let editorPosition: Position; - let top: number; - let left: number; - let lineLength: number; - - try { - editorPosition = toEditorPosition(model, position); - top = editor.getTopForLineNumber(position.lineNumber); - left = editor.getOffsetForColumn(position.lineNumber, position.column); - lineLength = model.getLineLength(position.lineNumber); - } catch (error) { - if (model.isDisposed()) return null; - throw error; - } - const modelLine = position.lineNumber - 1; - - return { - ...editorPosition, - viewLine: modelLine, - modelLine, - top, - left, - height: lineHeight, - segment: { - viewLine: modelLine, - modelLine, - startColumn: 0, - endColumn: lineLength, - top, - height: lineHeight, - }, - }; - }); + onModelPositionResolverChange?.( + createMonacoModelPositionResolver(() => editorRef.current, lineHeight), + ); return () => { onModelPositionResolverChange?.(null); @@ -2707,7 +2795,10 @@ export function MonacoEditor({ const navigationRevision = editorAPI.getOwnerNavigationRevision(viewStateKeyForRestore); const finishActivationRestore = viewStateRestoreGate.begin(); - editor.layout(); + // No synchronous layout() here: reading the container size in the middle + // of the commit forced a whole-window style and layout pass on every tab + // activation. The hidden slot keeps its cached size, automaticLayout picks + // up resizes, and the restore frame below lays the editor out once. applyMonacoSurfaceViewState(editor, viewStateKeyForRestore); const cancelCachedViewStateRestore = scheduleCachedViewStateRestore({ diff --git a/windows/tauri/src/features/editor/components/stylesheet.tsx b/windows/tauri/src/features/editor/components/stylesheet.tsx deleted file mode 100644 index deeed27819..0000000000 --- a/windows/tauri/src/features/editor/components/stylesheet.tsx +++ /dev/null @@ -1,51 +0,0 @@ -export function EditorStylesheet() { - return ( - - ); -} diff --git a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts index 9cfa40d9b3..217d3418f3 100644 --- a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts +++ b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts @@ -21,6 +21,7 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { filePathFromUri } from "@/features/editor/lsp/workspace-edit"; import { MONACO_HIGHLIGHT_LANGUAGE_IDS } from "./language"; import { filePathFromLitheModelUri } from "./model-uri"; +import { createHiddenModelRefresh } from "./surface-visibility"; const EXECUTE_LSP_CODE_LENS_COMMAND = "lithe.executeLspCodeLens"; const SHOW_REFERENCES_COMMAND = "editor.action.showReferences"; @@ -158,28 +159,43 @@ export function registerMonacoCodeLensProvider(): void { const lspClient = LspClient.getInstance(); const codeLensesChanged = new Emitter(); + // Monaco treats a failed provider as "no lenses", so a hidden tab gets its + // last lenses back instead of a server request on every document open. + const lastLenses = new WeakMap(); + const hiddenModels = createHiddenModelRefresh(() => codeLensesChanged.fire(provider)); const provider: Monaco.languages.CodeLensProvider = { onDidChange: codeLensesChanged.event, async provideCodeLenses(model, token) { - const filePath = filePathFromModel(model); - const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); - if ( - !target || - !isDocumentFeatureAvailable(lspClient.getDocumentAvailability(target, "codeLens")) || - (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) - ) { - return { lenses: [] }; + if (hiddenModels.skipIfHidden(model.uri.toString())) { + return lastLenses.get(model) ?? { lenses: [] }; } + const list = await provideVisibleCodeLenses(model, token); + if (!token.isCancellationRequested) lastLenses.set(model, list); + return list; + }, + }; + const provideVisibleCodeLenses = async ( + model: Monaco.editor.ITextModel, + token: Monaco.CancellationToken, + ): Promise => { + const filePath = filePathFromModel(model); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if ( + !target || + !isDocumentFeatureAvailable(lspClient.getDocumentAvailability(target, "codeLens")) || + (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) + ) { + return { lenses: [] }; + } - const lenses = await lspClient.getCodeLens(target); - if (token.isCancellationRequested) return { lenses: [] }; + const lenses = await lspClient.getCodeLens(target); + if (token.isCancellationRequested) return { lenses: [] }; - return { - lenses: lenses - .map((lens) => toMonacoCodeLens(filePath, lens, target)) - .filter((lens): lens is Monaco.languages.CodeLens => lens !== null), - }; - }, + return { + lenses: lenses + .map((lens) => toMonacoCodeLens(filePath, lens, target)) + .filter((lens): lens is Monaco.languages.CodeLens => lens !== null), + }; }; useLspStore.subscribe((state, previousState) => { const currentStatus = state.lspStatus; diff --git a/windows/tauri/src/features/editor/engines/monaco/hover-widgets.test.ts b/windows/tauri/src/features/editor/engines/monaco/hover-widgets.test.ts new file mode 100644 index 0000000000..a26b9f3b83 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/hover-widgets.test.ts @@ -0,0 +1,38 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { clampMonacoHoverWidgets } from "./hover-widgets"; + +const restoreDom = installHappyDom(); + +afterAll(() => { + restoreDom(); +}); + +const HOVER_MAX_WIDTH_PROPERTY = "--lithe-monaco-hover-max-width"; + +describe("Monaco hover clamp", () => { + test("writes nothing to the editor when no hover is shown", () => { + const container = document.createElement("div"); + container.innerHTML = '
'; + const before = container.outerHTML; + + clampMonacoHoverWidgets(container); + + expect(container.outerHTML).toBe(before); + expect(container.style.getPropertyValue(HOVER_MAX_WIDTH_PROPERTY)).toBe(""); + }); + + test("sets the width limit on the hover widget instead of the editor container", () => { + const container = document.createElement("div"); + const widget = document.createElement("div"); + widget.setAttribute("widgetid", "editor.contrib.resizableContentHoverWidget"); + widget.innerHTML = '
'; + container.appendChild(widget); + + clampMonacoHoverWidgets(container); + + expect(container.style.getPropertyValue(HOVER_MAX_WIDTH_PROPERTY)).toBe(""); + expect(widget.style.getPropertyValue(HOVER_MAX_WIDTH_PROPERTY)).toMatch(/^\d+px$/); + expect(widget.style.maxWidth).toBe(widget.style.getPropertyValue(HOVER_MAX_WIDTH_PROPERTY)); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/hover-widgets.ts b/windows/tauri/src/features/editor/engines/monaco/hover-widgets.ts index 6b8730dda9..3a29c5562a 100644 --- a/windows/tauri/src/features/editor/engines/monaco/hover-widgets.ts +++ b/windows/tauri/src/features/editor/engines/monaco/hover-widgets.ts @@ -3,6 +3,7 @@ import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; const MONACO_HOVER_MIN_WIDTH = 120; const MONACO_HOVER_MAX_WIDTH = 720; const MONACO_HOVER_MIN_HEIGHT = 48; +const HOVER_MAX_WIDTH_PROPERTY = "--lithe-monaco-hover-max-width"; const MONACO_HOVER_WIDGET_SELECTOR = '[widgetid="editor.contrib.resizableContentHoverWidget"], [widgetid="editor.contrib.modesGlyphHoverWidget"]'; @@ -58,19 +59,24 @@ function getMonacoHoverContentWidth(nodes: Array) { }, MONACO_HOVER_MIN_WIDTH); } -export function syncMonacoHoverBounds(container: HTMLElement) { - const maxWidth = getMonacoHoverMaxWidth(container); - container.style.setProperty("--lithe-monaco-hover-max-width", `${maxWidth}px`); -} - +/** + * Clamps visible hover widgets to the editor. The width limit is a custom + * property on each widget rather than the editor container: changing a custom + * property restyles every element below it, and the container holds the whole + * editor, so a container-level write cost a full editor restyle on each open + * and resize. Without a hover there is nothing to read or write. + */ export function clampMonacoHoverWidgets(container: HTMLElement) { - syncMonacoHoverBounds(container); + const widgetNodes = container.querySelectorAll(MONACO_HOVER_WIDGET_SELECTOR); + if (widgetNodes.length === 0) return; const maxWidth = getMonacoHoverMaxWidth(container); const maxHeight = getMonacoHoverMaxHeight(container); - const widgetNodes = container.querySelectorAll(MONACO_HOVER_WIDGET_SELECTOR); for (const widgetNode of widgetNodes) { + if (widgetNode.style.getPropertyValue(HOVER_MAX_WIDTH_PROPERTY) !== `${maxWidth}px`) { + widgetNode.style.setProperty(HOVER_MAX_WIDTH_PROPERTY, `${maxWidth}px`); + } const hoverNode = widgetNode.querySelector(".monaco-hover") ?? widgetNode; const scrollNode = hoverNode.querySelector(".monaco-scrollable-element"); const contentNode = hoverNode.querySelector(".monaco-hover-content"); diff --git a/windows/tauri/src/features/editor/engines/monaco/input-options.ts b/windows/tauri/src/features/editor/engines/monaco/input-options.ts new file mode 100644 index 0000000000..e23b970e8c --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/input-options.ts @@ -0,0 +1,17 @@ +import type * as Monaco from "monaco-editor"; + +/** + * Input options shared by every Monaco editor Lithe creates. + * + * Monaco's EditContext input reads the editor's on-screen bounds after every + * render so the IME knows where the caret is. Each read forces a synchronous + * style and layout pass, and every mounted editor (hidden tabs included) + * renders when language-server decorations arrive, so jumping between classes + * paid several whole-window layouts per frame. The textarea input Monaco used + * before positions its hidden textarea without reading layout, supports IME + * composition, and is what Lithe's keyboard routing already expects + * (`textarea.inputarea`). + */ +export const MONACO_INPUT_OPTIONS = { + editContext: false, +} as const satisfies Monaco.editor.IEditorOptions; diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index a9b366f89c..2b405ae14e 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -40,6 +40,7 @@ import { } from "./lsp-completion"; import { filePathFromLitheModelUri } from "./model-uri"; import { createMonacoSemanticTokenProvider } from "./semantic-token-provider"; +import { createHiddenModelRefresh } from "./surface-visibility"; let providersRegistered = false; @@ -385,6 +386,9 @@ export function registerMonacoLspProviders() { void listen<{ sessionId: string }>("lsp://semantic-tokens-refresh", ({ payload }) => { if (ownsLspSession(payload.sessionId)) semanticTokensChanged.fire(); }); + // Every document open bumps the revision above; hidden tabs keep their + // colors instead of each asking the server again, and refresh when shown. + const hiddenSemanticTokens = createHiddenModelRefresh(() => semanticTokensChanged.fire()); languages.registerDocumentSemanticTokensProvider( selector, createMonacoSemanticTokenProvider({ @@ -392,6 +396,7 @@ export function registerMonacoLspProviders() { filePathFromModel, isLspModel: (model) => model.uri.scheme === "file" || model.uri.scheme === "lithe", onDidChange: semanticTokensChanged.event, + skipHiddenModel: (model) => hiddenSemanticTokens.skipIfHidden(model.uri.toString()), }), ); diff --git a/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.test.ts b/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.test.ts new file mode 100644 index 0000000000..3c51e35ffb --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { createMonacoModelPositionResolver } from "./model-position-resolver"; + +function model(lines: string[]) { + let disposed = false; + return { + isDisposed: () => disposed, + dispose: () => { + disposed = true; + }, + validatePosition(position: { lineNumber: number; column: number }) { + const lineNumber = Math.max(1, Math.min(lines.length, position.lineNumber)); + const column = Math.max(1, Math.min(lines[lineNumber - 1].length + 1, position.column)); + return { lineNumber, column }; + }, + getOffsetAt(position: { lineNumber: number; column: number }) { + return ( + lines.slice(0, position.lineNumber - 1).reduce((sum, line) => sum + line.length + 1, 0) + + position.column - + 1 + ); + }, + getLineLength: (lineNumber: number) => lines[lineNumber - 1].length, + }; +} + +function surface() { + let current: ReturnType | null = null; + return { + attach(next: typeof current) { + current = next; + }, + editor: { + getModel: () => current, + getTopForLineNumber: (lineNumber: number) => 100 + (lineNumber - 1) * 22, + getOffsetForColumn: (_lineNumber: number, column: number) => (column - 1) * 8, + }, + }; +} + +describe("live Monaco model position resolver", () => { + test("resolves after an initially unloaded surface gets its editor and model", () => { + const view = surface(); + let currentEditor: typeof view.editor | null = null; + const resolve = createMonacoModelPositionResolver(() => currentEditor, 22); + expect(resolve(1, 4)).toBeNull(); + currentEditor = view.editor; + expect(resolve(1, 4)).toBeNull(); + + view.attach(model(["alpha", "second"])); + expect(resolve(1, 4)).toMatchObject({ + line: 1, + column: 4, + offset: 10, + top: 122, + left: 32, + height: 22, + segment: { modelLine: 1, endColumn: 6 }, + }); + }); + + test("one resolver follows a reused editor to a shorter model", () => { + const view = surface(); + const first = model(["alpha", "second"]); + view.attach(first); + const resolve = createMonacoModelPositionResolver(() => view.editor, 22); + expect(resolve(1, 4)?.line).toBe(1); + + first.dispose(); + view.attach(model(["x"])); + expect(resolve(1, 4)).toMatchObject({ + line: 0, + column: 1, + offset: 1, + top: 100, + left: 8, + segment: { modelLine: 0, endColumn: 1 }, + }); + }); + + test("stops resolving detached or disposed models and resumes after loading", () => { + const view = surface(); + const first = model(["alpha"]); + view.attach(first); + const resolve = createMonacoModelPositionResolver(() => view.editor, 22); + first.dispose(); + expect(resolve(0, 0)).toBeNull(); + view.attach(null); + expect(resolve(0, 0)).toBeNull(); + view.attach(model(["next"])); + expect(resolve(0, 2)).toMatchObject({ line: 0, column: 2, left: 16 }); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.ts b/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.ts new file mode 100644 index 0000000000..bb85cde502 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/model-position-resolver.ts @@ -0,0 +1,49 @@ +import type { editor, IPosition } from "monaco-editor"; +import type { EditorModelPositionResolver } from "../../view-model/view-layout"; + +type PositionModel = Pick & { + validatePosition(position: IPosition): IPosition; +}; +type PositionEditor = Pick & { + getModel(): PositionModel | null; +}; + +/** Resolve against the live model: a reused surface may be loading or showing another file. */ +export function createMonacoModelPositionResolver( + getEditor: () => PositionEditor | null, + lineHeight: number, +): EditorModelPositionResolver { + return (line, column) => { + const view = getEditor(); + const model = view?.getModel(); + if (!view || !model || model.isDisposed()) return null; + const position = model.validatePosition({ lineNumber: line + 1, column: column + 1 }); + + try { + const modelLine = position.lineNumber - 1; + const top = view.getTopForLineNumber(position.lineNumber); + const lineLength = model.getLineLength(position.lineNumber); + return { + line: modelLine, + column: position.column - 1, + offset: model.getOffsetAt(position), + viewLine: modelLine, + modelLine, + top, + left: view.getOffsetForColumn(position.lineNumber, position.column), + height: lineHeight, + segment: { + viewLine: modelLine, + modelLine, + startColumn: 0, + endColumn: lineLength, + top, + height: lineHeight, + }, + }; + } catch (error) { + if (model.isDisposed()) return null; + throw error; + } + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts index 57b03afdbb..a30e40554a 100644 --- a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts +++ b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts @@ -3,7 +3,7 @@ import type * as Monaco from "monaco-editor"; import fixture from "../../../../../../../shared/fixtures/lsp/semantic-tokens-v1.json"; import { registerLspDocumentChangeFlusher } from "@/features/editor/lsp/pending-document-changes"; import type { LspSemanticTokensResponse } from "@/features/editor/lsp/semantic-token-types"; -import { createMonacoSemanticTokenProvider } from "./semantic-token-provider"; +import { createMonacoSemanticTokenProvider, HIDDEN_MODEL_BUSY_MESSAGE } from "./semantic-token-provider"; function harness() { const state = { @@ -106,3 +106,27 @@ test("keeps basic highlighting available when Core returns no semantic colors", h.request.mockResolvedValue(null); expect(await h.provide()).toBeNull(); }); + +test("keeps current colors for a model only hidden tabs show", async () => { + const request = mock(async (): Promise => null); + const model = { + uri: { toString: () => "file:///C:/work/Main.java" }, + } as unknown as Monaco.editor.ITextModel; + const provider = createMonacoSemanticTokenProvider({ + client: { + getActiveServerEntryForFile: () => ({}), + isDocumentOpen: () => true, + getSemanticTokens: request, + }, + filePathFromModel: () => "C:/work/Main.java", + isLspModel: () => true, + skipHiddenModel: () => true, + }); + const token = { isCancellationRequested: false } as Monaco.CancellationToken; + + // Monaco treats a "busy" failure as "keep the tokens you have". + await expect(provider.provideDocumentSemanticTokens(model, null, token)).rejects.toThrow( + HIDDEN_MODEL_BUSY_MESSAGE, + ); + expect(request).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts index f27d7ba1b6..a74aeeaa7b 100644 --- a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts +++ b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts @@ -14,13 +14,19 @@ interface SemanticTokenProviderOptions { filePathFromModel(model: Monaco.editor.ITextModel): string; isLspModel(model: Monaco.editor.ITextModel): boolean; onDidChange?: Monaco.Emitter["event"]; + /** True for models only hidden editor tabs show; they keep their tokens. */ + skipHiddenModel?(model: Monaco.editor.ITextModel): boolean; } +/** Monaco keeps current tokens, without logging, when a request fails as busy. */ +export const HIDDEN_MODEL_BUSY_MESSAGE = "busy: the editor showing this model is hidden"; + export function createMonacoSemanticTokenProvider({ client, filePathFromModel, isLspModel, onDidChange, + skipHiddenModel, }: SemanticTokenProviderOptions): Monaco.languages.DocumentSemanticTokensProvider { return { onDidChange, @@ -29,6 +35,7 @@ export function createMonacoSemanticTokenProvider({ }, async provideDocumentSemanticTokens(model, _lastResultId, cancellationToken) { if (cancellationToken.isCancellationRequested || !isLspModel(model)) return null; + if (skipHiddenModel?.(model)) throw new Error(HIDDEN_MODEL_BUSY_MESSAGE); const filePath = filePathFromModel(model); if (!client.getActiveServerEntryForFile(filePath) || !client.isDocumentOpen(filePath)) { diff --git a/windows/tauri/src/features/editor/engines/monaco/surface-visibility.test.ts b/windows/tauri/src/features/editor/engines/monaco/surface-visibility.test.ts new file mode 100644 index 0000000000..b184c0adbc --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/surface-visibility.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + createHiddenModelRefresh, + isMonacoModelOnlyHidden, + registerMonacoSurface, + resetMonacoSurfacesForTests, +} from "./surface-visibility"; + +const URI = "lithe:/C:/work/Main.java"; + +afterEach(() => { + resetMonacoSurfacesForTests(); +}); + +describe("Monaco surface visibility", () => { + test("never treats a model without registered surfaces as hidden", () => { + expect(isMonacoModelOnlyHidden(URI)).toBe(false); + const refresh = createHiddenModelRefresh(() => {}); + expect(refresh.skipIfHidden(URI)).toBe(false); + }); + + test("treats a model as hidden only while every surface showing it is hidden", () => { + const first = registerMonacoSurface(URI, false); + expect(isMonacoModelOnlyHidden(URI)).toBe(true); + + const second = registerMonacoSurface(URI, true); + expect(isMonacoModelOnlyHidden(URI)).toBe(false); + + second.setVisible(false); + expect(isMonacoModelOnlyHidden(URI)).toBe(true); + + first.dispose(); + second.dispose(); + expect(isMonacoModelOnlyHidden(URI)).toBe(false); + }); + + test("refreshes once when a skipped model is shown again", () => { + let refreshes = 0; + const refresh = createHiddenModelRefresh(() => { + refreshes++; + }); + const surface = registerMonacoSurface(URI, false); + + expect(refresh.skipIfHidden(URI)).toBe(true); + expect(refresh.skipIfHidden(URI)).toBe(true); + surface.setVisible(true); + expect(refreshes).toBe(1); + + surface.setVisible(false); + surface.setVisible(true); + expect(refreshes).toBe(1); + refresh.dispose(); + }); + + test("ignores updates after dispose and repeated visibility values", () => { + let refreshes = 0; + const refresh = createHiddenModelRefresh(() => { + refreshes++; + }); + const surface = registerMonacoSurface(URI, false); + refresh.skipIfHidden(URI); + + surface.setVisible(false); + surface.dispose(); + surface.dispose(); + surface.setVisible(true); + + expect(refreshes).toBe(0); + expect(isMonacoModelOnlyHidden(URI)).toBe(false); + refresh.dispose(); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/surface-visibility.ts b/windows/tauri/src/features/editor/engines/monaco/surface-visibility.ts new file mode 100644 index 0000000000..66b39c4628 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/surface-visibility.ts @@ -0,0 +1,104 @@ +/** + * Editor tabs keep their Monaco surfaces mounted while hidden, so Monaco still + * treats their models as attached and asks providers to refresh them whenever + * a language server event fires. Surfaces report their visibility here so + * providers can skip models that only hidden surfaces show and refresh them + * once one of those surfaces is shown again. + * + * Models no surface registered (diff views, notebooks) are never treated as + * hidden. + */ +interface SurfaceCounts { + visible: number; + hidden: number; +} + +const surfaces = new Map(); +const shownListeners = new Set<(uri: string) => void>(); + +function counts(uri: string): SurfaceCounts { + let entry = surfaces.get(uri); + if (!entry) { + entry = { visible: 0, hidden: 0 }; + surfaces.set(uri, entry); + } + return entry; +} + +export interface MonacoSurfaceRegistration { + setVisible(visible: boolean): void; + dispose(): void; +} + +export function registerMonacoSurface(uri: string, visible: boolean): MonacoSurfaceRegistration { + let current = visible; + let disposed = false; + const entry = counts(uri); + if (current) entry.visible++; + else entry.hidden++; + if (current && entry.visible === 1) notifyShown(uri); + + return { + setVisible(next) { + if (disposed || next === current) return; + const target = counts(uri); + if (next) { + target.hidden--; + target.visible++; + } else { + target.visible--; + target.hidden++; + } + current = next; + if (next && target.visible === 1) notifyShown(uri); + }, + dispose() { + if (disposed) return; + disposed = true; + const target = counts(uri); + if (current) target.visible--; + else target.hidden--; + if (target.visible <= 0 && target.hidden <= 0) surfaces.delete(uri); + }, + }; +} + +/** Whether every registered surface showing this model is currently hidden. */ +export function isMonacoModelOnlyHidden(uri: string): boolean { + const entry = surfaces.get(uri); + return entry !== undefined && entry.visible <= 0 && entry.hidden > 0; +} + +export function onMonacoModelShown(listener: (uri: string) => void): () => void { + shownListeners.add(listener); + return () => shownListeners.delete(listener); +} + +function notifyShown(uri: string): void { + for (const listener of shownListeners) listener(uri); +} + +/** + * Remembers models a provider skipped while hidden and asks for one refresh + * when any of them is shown again. + */ +export function createHiddenModelRefresh(refresh: () => void) { + const skipped = new Set(); + const stop = onMonacoModelShown((uri) => { + if (skipped.delete(uri)) refresh(); + }); + return { + /** Returns true when the provider must not request data for this model now. */ + skipIfHidden(uri: string): boolean { + if (!isMonacoModelOnlyHidden(uri)) return false; + skipped.add(uri); + return true; + }, + dispose: stop, + }; +} + +export function resetMonacoSurfacesForTests(): void { + surfaces.clear(); + shownListeners.clear(); +} diff --git a/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.test.ts b/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.test.ts new file mode 100644 index 0000000000..b2b8364da7 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { MonacoThemeDefinitionCache } from "./theme-definition-cache"; + +describe("MonacoThemeDefinitionCache", () => { + test("an unchanged theme is defined once so editor mounts do not refresh Monaco's global theme", () => { + const cache = new MonacoThemeDefinitionCache(); + + expect(cache.needsDefinition("lithe-dark", { colors: { background: "#101010" } })).toBe(true); + expect(cache.needsDefinition("lithe-dark", { colors: { background: "#101010" } })).toBe(false); + }); + + test("changed colors and other theme names still reach Monaco", () => { + const cache = new MonacoThemeDefinitionCache(); + cache.needsDefinition("lithe-dark", { colors: { background: "#101010" } }); + + expect(cache.needsDefinition("lithe-dark", { colors: { background: "#202020" } })).toBe(true); + expect(cache.needsDefinition("lithe-dark-italic-comments", { colors: { background: "#202020" } })).toBe(true); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.ts b/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.ts new file mode 100644 index 0000000000..810ef66bef --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.ts @@ -0,0 +1,17 @@ +/** + * Redefining the active Monaco theme makes Monaco rebuild every theme CSS rule, + * recolor all models and announce a theme change, which re-tokenizes every open + * Java model. Every editor mount defines the current theme, so only changed + * definitions may reach Monaco. + */ +export class MonacoThemeDefinitionCache { + private readonly definitions = new Map(); + + /** Records `data` for `themeName`; returns whether Monaco still needs it. */ + needsDefinition(themeName: string, data: unknown): boolean { + const serialized = JSON.stringify(data); + if (this.definitions.get(themeName) === serialized) return false; + this.definitions.set(themeName, serialized); + return true; + } +} diff --git a/windows/tauri/src/features/editor/engines/monaco/theme.ts b/windows/tauri/src/features/editor/engines/monaco/theme.ts index c3dddf2ed2..1099e6da1a 100644 --- a/windows/tauri/src/features/editor/engines/monaco/theme.ts +++ b/windows/tauri/src/features/editor/engines/monaco/theme.ts @@ -7,6 +7,7 @@ import { import { themeRegistry } from "@/extensions/themes/theme-registry"; import type { ThemeDefinition } from "@/extensions/themes/theme.types"; import { toMonacoColor } from "./color"; +import { MonacoThemeDefinitionCache } from "./theme-definition-cache"; import { createMonacoTokenThemeRules, MONACO_TOKEN_THEME_INHERITS_BASE } from "./token-theme-rules"; function getThemeId(theme: string): string { @@ -153,12 +154,17 @@ function createMonacoThemeData( }; } +const definedMonacoThemes = new MonacoThemeDefinitionCache(); + export function defineMonacoTheme(themeId: string, italicComments = false): string { const theme = themeRegistry.getTheme(themeId); if (!theme) return getThemeId(themeId); const monacoThemeId = toMonacoThemeName(theme.id, italicComments); - monacoEditor.defineTheme(monacoThemeId, createMonacoThemeData(theme, italicComments)); + const themeData = createMonacoThemeData(theme, italicComments); + if (definedMonacoThemes.needsDefinition(monacoThemeId, themeData)) { + monacoEditor.defineTheme(monacoThemeId, themeData); + } return monacoThemeId; } diff --git a/windows/tauri/src/features/editor/notebook/notebook-code-cell-editor.tsx b/windows/tauri/src/features/editor/notebook/notebook-code-cell-editor.tsx index d45b927e7c..108d07aacf 100644 --- a/windows/tauri/src/features/editor/notebook/notebook-code-cell-editor.tsx +++ b/windows/tauri/src/features/editor/notebook/notebook-code-cell-editor.tsx @@ -11,6 +11,7 @@ import { toMonacoLanguageId } from "../engines/monaco/language"; import { defineActiveMonacoTheme, defineMonacoTheme } from "../engines/monaco/theme"; import { useMonacoEditorSettings } from "../engines/monaco/use-monaco-editor-settings"; import { EDITOR_CONSTANTS } from "../config/constants"; +import { MONACO_INPUT_OPTIONS } from "../engines/monaco/input-options"; interface NotebookCodeCellEditorProps { id: string; @@ -69,6 +70,7 @@ export function NotebookCodeCellEditor({ const model = monacoEditor.createModel(value, monacoLanguage, modelUri); const editor = monacoEditor.create(container, { + ...MONACO_INPUT_OPTIONS, model, automaticLayout: true, fontFamily, diff --git a/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts b/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts index f959e4fdb9..2565abfaeb 100644 --- a/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts +++ b/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts @@ -1,15 +1,24 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useBufferStore } from "./buffer.store"; const WORKSPACE = "buffer-agent-conversation-test"; +const defaultMaxOpenTabs = useSettingsStore.getState().settings.maxOpenTabs; +function setMaxOpenTabs(maxOpenTabs: number) { + useSettingsStore.setState((state) => { + state.settings.maxOpenTabs = maxOpenTabs; + }); +} + beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "Agent" }, "ready"); }); afterEach(() => { + setMaxOpenTabs(defaultMaxOpenTabs); useBufferStore.getStore(WORKSPACE).setState({ buffers: [], activeBufferId: null }); }); @@ -58,7 +67,7 @@ describe("openAgentConversationBuffer", () => { const store = useBufferStore.getStore(WORKSPACE); const id = store.getState().actions.openAgentConversationBuffer("session-a"); - store.setState({ maxOpenTabs: 1 }); + setMaxOpenTabs(1); store.getState().actions.openAgentConversationBuffer("session-b"); expect( diff --git a/windows/tauri/src/features/editor/stores/buffer-eviction.test.ts b/windows/tauri/src/features/editor/stores/buffer-eviction.test.ts new file mode 100644 index 0000000000..453da577d4 --- /dev/null +++ b/windows/tauri/src/features/editor/stores/buffer-eviction.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { PaneContent } from "@/features/panes/types/pane-content.types"; +import { markTabActivated, resetTabRecencyForTests } from "@/features/tabs/utils/tab-recency"; +import { evictLeastRecentAutoClosableBuffer, selectBuffersOverTabLimit } from "./buffer-eviction"; + +function tab(id: string, overrides: Record = {}): PaneContent { + return { id, type: "editor", path: `/${id}`, isPinned: false, isDirty: false, ...overrides } as PaneContent; +} + +const ids = (buffers: PaneContent[]) => buffers.map((buffer) => buffer.id); + +afterEach(() => { + resetTabRecencyForTests(); +}); + +describe("selectBuffersOverTabLimit", () => { + test("never closes terminals, agents, pinned or unsaved tabs, and does not count them", () => { + const buffers = [ + tab("terminal", { type: "terminal" }), + tab("agent", { type: "agent" }), + tab("conversation", { type: "agentConversation" }), + tab("pinned", { isPinned: true }), + tab("dirty", { isDirty: true }), + tab("a"), + tab("b"), + ]; + + expect(selectBuffersOverTabLimit(buffers, 2)).toEqual([]); + expect(ids(selectBuffersOverTabLimit(buffers, 1))).toEqual(["a"]); + }); + + test("treats 0 as no limit", () => { + expect(selectBuffersOverTabLimit([tab("a"), tab("b")], 0)).toEqual([]); + const { evictedBuffer } = evictLeastRecentAutoClosableBuffer([tab("a")], 0); + expect(evictedBuffer).toBeNull(); + }); + + test("closes the least recently used tabs by default", () => { + markTabActivated("a"); + markTabActivated("b"); + markTabActivated("c"); + markTabActivated("a"); + + expect(ids(selectBuffersOverTabLimit([tab("a"), tab("b"), tab("c")], 1))).toEqual(["b", "c"]); + }); + + test("closes the oldest opened tabs under that policy", () => { + markTabActivated("b"); + markTabActivated("a"); + + const buffers = [tab("a"), tab("b"), tab("c")]; + expect(ids(selectBuffersOverTabLimit(buffers, 2, { policy: "oldestOpened" }))).toEqual(["a"]); + }); + + test("keeps the requested tab open", () => { + const buffers = [tab("a"), tab("b")]; + expect(ids(selectBuffersOverTabLimit(buffers, 1, { keepBufferId: "a" }))).toEqual(["b"]); + }); + + test("makes room for a tab about to open", () => { + const buffers = [tab("a"), tab("b")]; + markTabActivated("a"); + + const { buffers: remaining, evictedBuffer } = evictLeastRecentAutoClosableBuffer(buffers, 2); + + expect(evictedBuffer?.id).toBe("b"); + expect(ids(remaining)).toEqual(["a"]); + }); +}); diff --git a/windows/tauri/src/features/editor/stores/buffer-eviction.ts b/windows/tauri/src/features/editor/stores/buffer-eviction.ts index 1d924de75b..b51a9c4da1 100644 --- a/windows/tauri/src/features/editor/stores/buffer-eviction.ts +++ b/windows/tauri/src/features/editor/stores/buffer-eviction.ts @@ -1,5 +1,7 @@ import { isSingletonToolBuffer } from "@/features/panes/constants/tool-buffers"; import { isEditorContent, type PaneContent } from "@/features/panes/types/pane-content.types"; +import type { TabLimitPolicy } from "@/features/settings/types/settings.types"; +import { orderTabsForClosing } from "@/features/tabs/utils/tab-recency"; const AUTO_EVICTION_PROTECTED_TYPES = new Set([ "agent", @@ -11,6 +13,7 @@ const AUTO_EVICTION_PROTECTED_TYPES = new Set([ export interface AutoEvictionOptions { includePreviews?: boolean; + policy?: TabLimitPolicy; } function canAutoEvictBuffer( @@ -24,17 +27,40 @@ function canAutoEvictBuffer( return !AUTO_EVICTION_PROTECTED_TYPES.has(buffer.type); } +/** + * Picks the tabs to close so at most `maxOpenTabs` auto-closable tabs remain, + * first ones first. The limit counts only tabs it may close: pinned and unsaved + * tabs, terminals and agents neither count nor close, and 0 means no limit. + * `reservedSlots` makes room for tabs about to open. + */ +export function selectBuffersOverTabLimit( + buffers: PaneContent[], + maxOpenTabs: number, + { + keepBufferId = null, + reservedSlots = 0, + ...options + }: AutoEvictionOptions & { keepBufferId?: string | null; reservedSlots?: number } = {}, +): PaneContent[] { + if (maxOpenTabs <= 0) return []; + const candidates = buffers.filter((buffer) => canAutoEvictBuffer(buffer, options)); + const excess = candidates.length + reservedSlots - maxOpenTabs; + if (excess <= 0) return []; + return orderTabsForClosing( + candidates.filter((buffer) => buffer.id !== keepBufferId), + options.policy ?? "leastRecentlyUsed", + ).slice(0, excess); +} + export function evictLeastRecentAutoClosableBuffer( buffers: PaneContent[], maxOpenTabs: number, options: AutoEvictionOptions = {}, ): { buffers: PaneContent[]; evictedBuffer: PaneContent | null } { - const candidates = buffers.filter((buffer) => canAutoEvictBuffer(buffer, options)); - if (candidates.length < maxOpenTabs) { - return { buffers, evictedBuffer: null }; - } - - const evictedBuffer = candidates[0] ?? null; + const [evictedBuffer] = selectBuffersOverTabLimit(buffers, maxOpenTabs, { + ...options, + reservedSlots: 1, + }); if (!evictedBuffer) { return { buffers, evictedBuffer: null }; } diff --git a/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts b/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts index 412a92ba61..ec2d0649c8 100644 --- a/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts +++ b/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { getBufferById } from "../utils/buffer-index"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useBufferStore } from "./buffer.store"; const WORKSPACE = "buffer-new-tab-test"; @@ -15,12 +16,20 @@ function getActiveEditor(): EditorContent { return buffer; } +const defaultMaxOpenTabs = useSettingsStore.getState().settings.maxOpenTabs; +function setMaxOpenTabs(maxOpenTabs: number) { + useSettingsStore.setState((state) => { + state.settings.maxOpenTabs = maxOpenTabs; + }); +} + beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "New Tab" }, "ready"); }); afterEach(() => { + setMaxOpenTabs(defaultMaxOpenTabs); useBufferStore.getStore(WORKSPACE).setState({ buffers: [], activeBufferId: null }); }); @@ -85,7 +94,7 @@ describe("showNewTabView", () => { test("keeps a dirty untitled tab instead of evicting it at the tab limit", () => { const store = useBufferStore.getStore(WORKSPACE); - store.setState({ maxOpenTabs: 1 }); + setMaxOpenTabs(1); store.getState().actions.showNewTabView(); const first = getActiveEditor(); @@ -98,9 +107,20 @@ describe("showNewTabView", () => { expect(buffers.filter((buffer) => buffer.path.startsWith("untitled:"))).toHaveLength(2); }); + test("follows the tab limit setting, where 0 means no limit", () => { + const store = useBufferStore.getStore(WORKSPACE); + setMaxOpenTabs(0); + + store.getState().actions.showNewTabView(); + const first = getActiveEditor(); + store.getState().actions.showNewTabView(); + + expect(store.getState().buffers.some((buffer) => buffer.id === first.id)).toBe(true); + }); + test("evicts a clean untitled tab once the tab limit is reached", () => { const store = useBufferStore.getStore(WORKSPACE); - store.setState({ maxOpenTabs: 1 }); + setMaxOpenTabs(1); store.getState().actions.showNewTabView(); const first = getActiveEditor(); diff --git a/windows/tauri/src/features/editor/stores/buffer.store.ts b/windows/tauri/src/features/editor/stores/buffer.store.ts index f7ddc53d92..320b928766 100644 --- a/windows/tauri/src/features/editor/stores/buffer.store.ts +++ b/windows/tauri/src/features/editor/stores/buffer.store.ts @@ -9,6 +9,7 @@ import { immer } from "zustand/middleware/immer"; import { createStore } from "zustand/vanilla"; import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { evictLeastRecentAutoClosableBuffer } from "@/features/editor/stores/buffer-eviction"; +import { forgetTab } from "@/features/tabs/utils/tab-recency"; import { createPaneContent, createRestoredEditorPlaceholder, @@ -48,7 +49,6 @@ import { usePaneStore } from "@/features/panes/stores/pane.store"; import { useProjectStore } from "@/features/window/stores/project.store"; import { SINGLETON_TOOL_BUFFER_METADATA } from "@/features/panes/constants/tool-buffers"; import { ensureBufferInPane as ensureBufferInWorkspacePane } from "@/features/panes/utils/pane-buffer-actions"; -import { defaultSettings } from "@/features/settings/config/default-settings"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { createTranslator } from "@/i18n/locale"; import { cleanupBufferHistoryTracking, trackImmediateBufferHistoryChange } from "@/features/editor/stores/buffer-history-tracking"; @@ -118,7 +118,6 @@ type ClosedBuffer = ClosedEditorLikeBuffer | ClosedDiffBuffer | ClosedPreviewBuf interface BufferState { buffers: PaneContent[]; activeBufferId: string | null; - maxOpenTabs: number; pendingClose: PendingClose | null; closedBuffersHistory: ClosedBuffer[]; actions: BufferActions; @@ -236,7 +235,6 @@ interface BufferActions { switchToNextBuffer: () => void; switchToPreviousBuffer: () => void; getActiveBuffer: () => PaneContent | null; - setMaxOpenTabs: (max: number) => void; reloadBufferFromDisk: (bufferId: string) => Promise; createRestoredBufferMetadata: (options: { path: string; @@ -354,20 +352,25 @@ function makeDocumentBufferOwner( const tracksUnsavedEdits = (buffer: EditorContent): boolean => !buffer.readOnly && (!buffer.isVirtual || buffer.path.startsWith("untitled:")); +const releaseClosedBufferTracking = (bufferId: string) => { + cleanupBufferHistoryTracking(bufferId); + forgetTab(bufferId); +}; + const applyWorkspaceAutoEviction = ( buffers: PaneContent[], - maxOpenTabs: number, workspaceId: string, options?: { includePreviews?: boolean }, ): PaneContent[] => { + const { maxOpenTabs, tabLimitPolicy } = useSettingsStore.getState().settings; const { buffers: nextBuffers, evictedBuffer } = evictLeastRecentAutoClosableBuffer( buffers, maxOpenTabs, - options, + { ...options, policy: tabLimitPolicy }, ); if (evictedBuffer) { - cleanupBufferHistoryTracking(evictedBuffer.id); + releaseClosedBufferTracking(evictedBuffer.id); removeBufferFromWorkspacePanes(evictedBuffer.id, false, workspaceId); } @@ -617,11 +620,8 @@ const scheduleExtensionSupportCheck = (path: string) => { const createBufferStore = (workspaceId: string) => { const paneStore = usePaneStore.getStore(workspaceId); let restorePromoter: ((bufferId: string) => void) | null = null; - const applyAutoEviction = ( - buffers: PaneContent[], - maxOpenTabs: number, - options?: { includePreviews?: boolean }, - ) => applyWorkspaceAutoEviction(buffers, maxOpenTabs, workspaceId, options); + const applyAutoEviction = (buffers: PaneContent[], options?: { includePreviews?: boolean }) => + applyWorkspaceAutoEviction(buffers, workspaceId, options); const ensureBufferInPane = (paneId: string, bufferId: string, setActive = true) => ensureBufferInWorkspacePane(paneId, bufferId, setActive, workspaceId); const getPaneReplacementBufferId = (closingBufferIds: string[], buffers: PaneContent[]) => @@ -644,12 +644,11 @@ const createBufferStore = (workspaceId: string) => { immer((set, get) => ({ buffers: [], activeBufferId: null, - maxOpenTabs: defaultSettings.maxOpenTabs, pendingClose: null, closedBuffersHistory: [], actions: { openContent: (spec: OpenContentSpec): string => { - const { buffers, maxOpenTabs } = get(); + const { buffers } = get(); switch (spec.type) { case "editor": { @@ -678,13 +677,13 @@ const createBufferStore = (workspaceId: string) => { ? getBufferById(newBuffers, previewTargetPane.previewBufferId) : null; if (existingPreview?.isPreview) { - cleanupBufferHistoryTracking(existingPreview.id); + releaseClosedBufferTracking(existingPreview.id); removeBufferFromPanes(existingPreview.id, true); newBuffers = newBuffers.filter((b) => b.id !== existingPreview.id); } } - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs, { + newBuffers = applyAutoEviction(newBuffers, { includePreviews: false, }); @@ -727,7 +726,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, { @@ -772,7 +771,7 @@ const createBufferStore = (workspaceId: string) => { const displayName = `Agent ${agentNumber}`; let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, { @@ -804,7 +803,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -842,7 +841,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -861,7 +860,7 @@ const createBufferStore = (workspaceId: string) => { case "markdownDocument": { const path = `markdown-document://${spec.documentId}`; let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -911,7 +910,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -958,7 +957,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -1004,7 +1003,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -1032,7 +1031,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -1064,7 +1063,7 @@ const createBufferStore = (workspaceId: string) => { logger.error("BufferStore", "Failed to close old external editor terminal:", e); }); } - cleanupBufferHistoryTracking(existingExternalEditor.id); + releaseClosedBufferTracking(existingExternalEditor.id); removeBufferFromPanes(existingExternalEditor.id); newBuffers = newBuffers.filter((b) => b.id !== existingExternalEditor.id); } @@ -1096,7 +1095,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(SINGLETON_TOOL_BUFFER_METADATA[spec.type].path); const newBuffer = createPaneContent(id, spec); @@ -1122,7 +1121,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs); + newBuffers = applyAutoEviction(newBuffers); const id = generateBufferId(path); const newBuffer = createPaneContent(id, spec); @@ -1160,7 +1159,7 @@ const createBufferStore = (workspaceId: string) => { } let newBuffers = [...buffers]; - newBuffers = applyAutoEviction(newBuffers, maxOpenTabs, { + newBuffers = applyAutoEviction(newBuffers, { includePreviews: false, }); @@ -1402,7 +1401,7 @@ const createBufferStore = (workspaceId: string) => { if (bufferIndex === -1) return; - cleanupBufferHistoryTracking(bufferId); + releaseClosedBufferTracking(bufferId); const replacementBufferId = activeBufferId === bufferId ? getPaneReplacementBufferId([bufferId], buffers) : null; @@ -1993,11 +1992,6 @@ const createBufferStore = (workspaceId: string) => { return getBufferById(buffers, activeBufferId); }, - setMaxOpenTabs: (max: number) => { - set((state) => { - state.maxOpenTabs = max; - }); - }, reloadBufferFromDisk: async (bufferId: string): Promise => { const buffer = getBufferById(get().buffers, bufferId); diff --git a/windows/tauri/src/features/editor/styles/editor-shell.css b/windows/tauri/src/features/editor/styles/editor-shell.css new file mode 100644 index 0000000000..cffc9ca118 --- /dev/null +++ b/windows/tauri/src/features/editor/styles/editor-shell.css @@ -0,0 +1,50 @@ +/* + * Editor shell rules. They live in a static stylesheet rather than a