From d78cb8917ed2cb2eaae86b02d70ffca288ea4de0 Mon Sep 17 00:00:00 2001 From: zhengfuping Date: Fri, 30 Oct 2026 19:25:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?perf(windows):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=89=93=E5=BC=80=E6=80=A7=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复用 Monaco 编辑器表面与模型,保留隐藏标签布局并按可见性调度语言服务 - 缓存主题定义、静态编辑器样式和输入配置,减少全窗口样式与布局计算 - 仅为未保存编辑发送 Spring、MyBatis 索引覆盖,合并扫描并跳过无 XML 映射的工作区 - 稳定文件树、快捷键和拖放回调,减少布局测量、重绘与原生监听注册 - 文件读取使用原始二进制响应,合并前端日志 IPC 并保留上游异步文件操作 - 优化标签菜单渲染,新增最近最少使用关闭策略,默认标签上限调整为 20 - 修复模型复用后的浮层位置解析,同步国际化、能力矩阵与回归测试 --- shared/i18n/locales/en-US.json | 6 +- shared/i18n/locales/zh-CN.json | 6 +- shared/i18n/messages.json | 28 + .../features/editor-language-basics.json | 8 +- windows/tauri/src-tauri/src/host.rs | 7 +- windows/tauri/src-tauri/src/logging.rs | 25 + windows/tauri/src-tauri/src/main.rs | 1 + .../editor/components/code-editor.tsx | 35 +- .../editor/components/monaco-editor.tsx | 816 ++++++++++-------- .../features/editor/components/stylesheet.tsx | 51 -- .../engines/monaco/code-lens-provider.ts | 48 +- .../engines/monaco/hover-widgets.test.ts | 38 + .../editor/engines/monaco/hover-widgets.ts | 20 +- .../editor/engines/monaco/input-options.ts | 17 + .../editor/engines/monaco/lsp-providers.ts | 5 + .../monaco/model-position-resolver.test.ts | 93 ++ .../engines/monaco/model-position-resolver.ts | 49 ++ .../monaco/semantic-token-provider.test.ts | 26 +- .../engines/monaco/semantic-token-provider.ts | 7 + .../engines/monaco/surface-visibility.test.ts | 72 ++ .../engines/monaco/surface-visibility.ts | 104 +++ .../monaco/theme-definition-cache.test.ts | 19 + .../engines/monaco/theme-definition-cache.ts | 17 + .../features/editor/engines/monaco/theme.ts | 8 +- .../notebook/notebook-code-cell-editor.tsx | 2 + .../features/editor/styles/editor-shell.css | 50 ++ .../utils/workspace-index-overrides.test.ts | 74 ++ .../editor/utils/workspace-index-overrides.ts | 71 ++ .../components/file-explorer-tree.tsx | 38 +- .../components/file-explorer-viewport.tsx | 5 + .../lib/tree-guide-visibility.test.ts | 53 ++ .../lib/tree-guide-visibility.ts | 59 ++ .../styles/file-explorer-tree-guides.test.ts | 6 +- .../styles/file-explorer-tree.css | 5 +- .../use-file-system-folder-drop.test.tsx | 97 +++ .../hooks/use-file-system-folder-drop.ts | 40 +- .../diff/independent-commit-diff-surface.ts | 2 + .../components/log/git-commit-file-tree.tsx | 6 +- .../components/status/git-status-panel.tsx | 4 + .../git/hooks/use-tree-content-width.ts | 26 +- .../src/features/keymaps/hooks/use-keymaps.ts | 15 +- .../logging/frontend-log-runtime.test.ts | 78 ++ .../features/logging/frontend-log-runtime.ts | 129 ++- .../mybatis/hooks/use-mybatis-index.ts | 71 +- .../mybatis/utils/mybatis-index-paths.test.ts | 14 +- .../mybatis/utils/mybatis-index-paths.ts | 9 + .../panes/components/pane-container.tsx | 57 +- .../utils/mounted-editor-buffers.test.ts | 36 +- .../panes/utils/mounted-editor-buffers.ts | 26 + .../components/tabs/editor-settings.tsx | 23 + .../settings/config/default-settings.ts | 3 +- .../settings/lib/settings-normalization.ts | 14 + .../features/settings/types/settings.types.ts | 3 + .../spring/hooks/use-spring-index.test.tsx | 54 ++ .../features/spring/hooks/use-spring-index.ts | 32 +- .../src/features/tabs/components/tab-bar.tsx | 355 +++++--- .../features/tabs/utils/tab-recency.test.ts | 43 + .../src/features/tabs/utils/tab-recency.ts | 37 + windows/tauri/src/i18n/locale-provider.tsx | 7 +- windows/tauri/src/i18n/shared.generated.ts | 12 +- windows/tauri/src/styles/utilities.css | 11 +- windows/tauri/src/ui/alert.tsx | 10 +- 62 files changed, 2383 insertions(+), 700 deletions(-) delete mode 100644 windows/tauri/src/features/editor/components/stylesheet.tsx create mode 100644 windows/tauri/src/features/editor/engines/monaco/hover-widgets.test.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/input-options.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/model-position-resolver.test.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/model-position-resolver.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/surface-visibility.test.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/surface-visibility.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.test.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/theme-definition-cache.ts create mode 100644 windows/tauri/src/features/editor/styles/editor-shell.css create mode 100644 windows/tauri/src/features/editor/utils/workspace-index-overrides.test.ts create mode 100644 windows/tauri/src/features/editor/utils/workspace-index-overrides.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/tree-guide-visibility.test.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/tree-guide-visibility.ts create mode 100644 windows/tauri/src/features/file-system/hooks/use-file-system-folder-drop.test.tsx create mode 100644 windows/tauri/src/features/tabs/utils/tab-recency.test.ts create mode 100644 windows/tauri/src/features/tabs/utils/tab-recency.ts diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 1d5cce6093..ac55176ed9 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -7340,7 +7340,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 once more than this many are open", + "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 f20a9de332..08f5e32917 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -7340,7 +7340,11 @@ "settings.editor.lintOnSave": "保存时检查", "settings.editor.lintOnSaveDescription": "保存文件时运行检查器", "settings.editor.maxOpenTabs": "最大打开标签数", - "settings.editor.maxOpenTabsDescription": "达到此数量后关闭最早的标签页", + "settings.editor.maxOpenTabsDescription": "打开的标签超过此数量时自动关闭标签页", + "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 c1d7c0818b..66971ec5fd 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -69206,6 +69206,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/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 45b2da2c49..62dac26d44 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, @@ -242,18 +245,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(() => { @@ -623,8 +639,11 @@ const CodeEditor = ({ paneId, viewStateKey: editorViewKey ?? undefined, isActiveSurface, + isVisibleSurface, isPreviewMode: isPreviewBuffer, enableExpensiveServices: enableRichEditorServices, + enableRichViewOptions, + isContentReady: !isRestorePending, readOnly, scrollable, alwaysConsumeMouseWheel: alwaysConsumeMouseWheel, @@ -646,7 +665,6 @@ const CodeEditor = ({ return ( <> -
{/* Breadcrumbs */} {showToolbar && ( @@ -729,11 +747,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 037f484d9d..4c53c77f68 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -113,7 +113,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, @@ -122,8 +125,8 @@ 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 { @@ -131,6 +134,7 @@ import { type InlineGitBlameWidget, } from "../engines/monaco/inline-git-blame-widget"; 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, sourcePositionAt } from "@lithe/editor/model-source"; @@ -166,6 +170,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"; registerMonacoLspProviders(); registerMonacoCodeLensProvider(); @@ -311,13 +316,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; @@ -356,8 +385,11 @@ export function MonacoEditor({ paneId, viewStateKey, isActiveSurface = true, + isVisibleSurface = isActiveSurface, isPreviewMode = false, enableExpensiveServices = true, + enableRichViewOptions = enableExpensiveServices, + isContentReady = true, readOnly = false, scrollable = true, alwaysConsumeMouseWheel = true, @@ -423,6 +455,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( @@ -660,6 +694,7 @@ export function MonacoEditor({ onScrollMetricsChangeRef.current = onScrollMetricsChange; onEditorScrollApiReadyRef.current = onEditorScrollApiReady; enableExpensiveServicesRef.current = enableExpensiveServices; + enableRichViewOptionsRef.current = enableRichViewOptions; const isCurrentEditorSurface = useCallback(() => { if (!isActiveSurfaceRef.current) return false; @@ -671,6 +706,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]; @@ -1028,170 +1078,116 @@ 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 editorCallbacksRef = useRef({ + isCurrentEditorSurface, + onScrollOffsetChange, + scheduleInlineGitBlameRender, + selectEntireModel, + setContextMenuPosition, + setScrollForBuffer, + setViewportHeight, + syncCursorAndSelection, + }); + editorCallbacksRef.current = { + isCurrentEditorSurface, + onScrollOffsetChange, + scheduleInlineGitBlameRender, + 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, + lineNumbers: lineNumbers ? lineNumberFormatter : "off", + 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) }, - lineNumbers: lineNumbers ? lineNumberFormatter : "off", - 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 = () => { @@ -1220,12 +1216,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); @@ -1242,24 +1247,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, () => callbacks().selectEntireModel()); 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(), }; @@ -1298,13 +1297,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(); @@ -1313,10 +1311,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 = [ @@ -1370,12 +1364,12 @@ export function MonacoEditor({ event.target.position.column, ), ); - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); } } editor.focus(); - setContextMenuPosition({ x: event.event.posx, y: event.event.posy }); + callbacks().setContextMenuPosition({ x: event.event.posx, y: event.event.posy }); }), editor.onKeyDown((event) => { const browserEvent = event.browserEvent; @@ -1389,52 +1383,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 => { - const start = sourcePositionAt(previousContent, edit.offset); - const end = sourcePositionAt(previousContent, edit.offset + edit.length); - return { rangeOffset: edit.offset, rangeLength: edit.length, text: edit.text, - startLine: start.line, startColumn: start.column, endLine: end.line, endColumn: 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; if ( @@ -1453,7 +1407,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; } @@ -1477,7 +1431,7 @@ export function MonacoEditor({ lineNumber: marker.line + 1, column: marker.utf16Column + 1, }); - syncCursorAndSelection(); + callbacks().syncCursorAndSelection(); void keymapRegistry.executeCommand( marker.direction === "down" ? "editor.goToImplementation" @@ -1488,7 +1442,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 && @@ -1498,7 +1452,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 ( @@ -1511,18 +1465,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; @@ -1549,7 +1506,7 @@ export function MonacoEditor({ editor.onMouseUp(() => { if (!mouseSelectingRef.current) return; mouseSelectingRef.current = false; - if (!isCurrentEditorSurface()) { + if (!callbacks().isCurrentEditorSurface()) { mouseGestureStartRef.current = null; return; } @@ -1559,25 +1516,26 @@ export function MonacoEditor({ ); if (entry) useJumpListStore.getState().actions.recordCursorEntry(entry); mouseGestureStartRef.current = null; - scheduleInlineGitBlameRender(); + callbacks().scheduleInlineGitBlameRender(); }), editor.onDidChangeCursorSelection(() => { - if (!isCurrentEditorSurface()) return; + if (!modelBindingRef.current) return; + if (!callbacks().isCurrentEditorSurface()) return; if (suppressNextCursorSelectionSyncRef.current) { suppressNextCursorSelectionSyncRef.current = false; - scheduleInlineGitBlameRender(); + callbacks().scheduleInlineGitBlameRender(); return; } - syncCursorAndSelection(); - scheduleInlineGitBlameRender(); + callbacks().syncCursorAndSelection(); + callbacks().scheduleInlineGitBlameRender(); }), - 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, @@ -1585,18 +1543,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; } @@ -1606,12 +1565,216 @@ export function MonacoEditor({ ); if (entry) useJumpListStore.getState().actions.recordCursorEntry(entry); mouseGestureStartRef.current = null; - scheduleInlineGitBlameRender(); + callbacks().scheduleInlineGitBlameRender(); }; window.addEventListener("mouseup", handleWindowMouseUp); + return () => { + onModelPositionResolverChange?.(null); + 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; + } + 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 => { + const start = sourcePositionAt(previousContent, edit.offset); + const end = sourcePositionAt(previousContent, edit.offset + edit.length); + return { rangeOffset: edit.offset, rangeLength: edit.length, text: edit.text, + startLine: start.line, startColumn: start.column, endLine: end.line, endColumn: 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 || editorRef.current !== editor) return; + if (modelRef.current !== model || editorRef.current !== editor) return; if (!isCurrentEditorSurface() || mouseSelectingRef.current) return; const monacoPosition = toClampedMonacoPosition(model, position); const currentPosition = editor.getPosition(); @@ -1634,7 +1797,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)); @@ -1654,15 +1817,14 @@ export function MonacoEditor({ }); scheduleInlineGitBlameRender(); - 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(); }, @@ -1670,6 +1832,10 @@ export function MonacoEditor({ }); return () => { + // Menus opened on this file must not survive a switch to another one. + setRunMarkerMenu(null); + setContextMenuRunMarker(null); + editorCallbacksRef.current.setContextMenuPosition(null); cancelCreatedViewStateRestore(); finishCreatedRestore(); if (benchmarkRafId !== null) cancelAnimationFrame(benchmarkRafId); @@ -1677,20 +1843,13 @@ 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); + if (definitionLinkGestureRef.current === definitionLinkGesture) { + definitionLinkGestureRef.current = null; } if (gitBlameRenderFrameRef.current !== null) { cancelAnimationFrame(gitBlameRenderFrameRef.current); @@ -1703,39 +1862,26 @@ export function MonacoEditor({ } gitBlameWidgetRef.current = null; renderedGitBlameKeyRef.current = null; - 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; - } + 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(); @@ -1745,50 +1891,18 @@ export function MonacoEditor({ }; }, [ 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, scheduleInlineGitBlameRender, - selectEntireModel, - semanticTokens, - setScrollForBuffer, - setViewportHeight, - syncCursorAndSelection, - tabSize, themeId, viewStateKey, - wordWrap, editorBufferId, + isContentReady, isCurrentEditorSurface, ]); @@ -2366,20 +2480,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 }, @@ -2419,7 +2533,7 @@ export function MonacoEditor({ editorScrollBeyondLastLine, editorSmoothScrolling, editorStickyScroll, - enableExpensiveServices, + enableRichViewOptions, fontFamily, fontSize, highlightOccurrences, @@ -2525,52 +2639,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); @@ -2592,7 +2663,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/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