From f19da73da1809e0e88cf677b60f55c219f1eab55 Mon Sep 17 00:00:00 2001 From: arkleselect Date: Wed, 28 Oct 2026 08:00:25 -0700 Subject: [PATCH 01/21] =?UTF-8?q?feat(macos):=E5=AE=8C=E5=96=84=E7=B1=BB?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E8=B7=B3=E8=BD=AC=E4=B8=8E=E6=82=AC=E5=81=9C?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-09-15-macos-monaco-feasibility-probe.md | 61 +++++++ frontend/editor/src/workbench.ts | 98 ++++++++++- macos/EditorFrontend/index.html | 4 +- macos/EditorFrontend/main.ts | 18 ++ macos/Experiments/Monaco/main.swift | 4 + .../Monaco/workbench.integration.ts | 154 ++++++++++++++++++ .../LanguageNavigationCoordinator.swift | 5 + .../AppModel/AppModel+CodeNavigation.swift | 26 +++ .../MacOS/MonacoWorkbenchEditor.swift | 51 +++++- macos/Sources/Lithe/Theme/LitheTheme.swift | 8 +- .../Lithe/Views/Editor/EditorAreaView.swift | 17 +- .../Views/Language/JavaReferencesView.swift | 81 ++++----- .../Tests/LitheTests/BundledUIFontTests.swift | 9 +- .../LitheTests/ContextMenuCoverageTests.swift | 7 + .../EditorSyntaxHighlighterTests.swift | 12 ++ .../LanguageNavigationCoordinatorTests.swift | 10 ++ .../MonacoWorkbenchThemeTests.swift | 14 ++ .../features/editor-language-basics.json | 5 +- .../features/lsp-navigation-edits.json | 12 +- 19 files changed, 531 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md index 045bffc59..e9ef2632d 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md +++ b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md @@ -105,6 +105,26 @@ macOS 保留用户接受的 `FindBarView` 外观。`EditorChromeModel` 有意不 原编辑器的定义跳转和 Markdown 双向滚动继续走平台已有能力: - Cmd-click(Windows 为 Ctrl-click)、F12 和右键 Go to Definition 在主/分屏 view 使用同一条带 drain 的请求, 发送该 view 捕获的 UTF-16 位置与 revision,关闭 hold 和切换 model 会取消请求;原生端复核后调用原有导航服务。 +- Command 悬停通过只读 `definitionHover` 请求查询现有 LSP/产品导航目标;不改变 caret、焦点、历史或结果面板。 + 手势复用锁定 Monaco 0.55.1 的 `ClickLinkGesture`,装饰复用 `goto-definition-link` 和宿主已注入的 + `editorLink.activeForeground`,不另猜颜色。按键释放、拖动、滚动、移出、失焦、编辑、切换或销毁视图 + 使旧结果失效;失败不缓存,后续手势可重试。点击继续复用原生定义导航,单目标直接打开,多目标才选择。 + 同文件目标不等于自身声明:只有点击位置在返回的声明区间内才允许查询实现,否则应直接定位同文件方法。 + 对照 IDEA Community `24bab9a438efb41bd197b295b87136bb98b0b40c` 的 + `platform/lang-impl/src/com/intellij/codeInsight/navigation/CtrlMouseHandler.kt` + (可导航时使用 `REFERENCE_HYPERLINK_COLOR` 与手形)及 + `navigation/actions/GotoDeclarationOnlyHandler2.kt`(单目标直接导航、多目标选择)。 + 不启用 Monaco 完整 definition contribution:它要求提前加载目标文件到 Web 模型库,会绕开宿主的 + 虚拟文档、只读路径校验与历史所有权;也不复用依赖 Windows LSP store 的平台手势实现。 + WebKit 回归验证装饰范围、实际 CSS、松键清除和悬停无导航副作用,Swift 回归保护同文件调用边界。 + macOS 的悬停文档面板通过 `popupStyles` 消费 `lithePopupChrome` 同一组表面/边框/文字/阴影 token 和圆角指标, + 不套用工具栏 `HoverTooltip` 的另一种视觉角色。正文读取共享 UI 字体,代码保持编程字体;现有只读 Fonts + adapter 允许随包已有的 Inter OTF,仍校验真实路径在字体目录内,不写发行资源、不影响签名或 delta。 + 普通悬停延迟为 500 ms,依据同 revision 的 `platform/ide-core-impl/src/com/intellij/openapi/editor/ex/EditorSettingsExternalizable.java` + 的 `TOOLTIPS_DELAY_RANGE` 初始值,以及 `platform/lang-impl/src/com/intellij/openapi/editor/HoverPopupContext.kt` 的剩余延迟计算。 + Command 导航提示沿 `CtrlMouseHandler.kt` 在语义结果返回后立即显示,复用 Monaco 文档面板的内容、屏幕定位、 + 滚动和键盘可访问性,不另建窗口或计时器;单目标导航会收起旧结果面板。Windows 宿主没有使用该 macOS 入口,未改其实现。 + - Markdown split 将原有 `MarkdownScrollPosition` binding 接回 Monaco,沿用来源与 revision 防回环; 只在该视图启用滚动消息,33 ms 合并连续输入,切换文档或接收预览滚动时撤销旧回传。 滚动消息不改文本或 undo;普通源码编辑不向原生工作台持续发送滚动状态。 @@ -640,6 +660,17 @@ Swift 同步桥接把内部位置字段编码为 `character`,Rust 要求 `utf1 共用资源构建。代价是新增一条需要逐步收敛的 Web 桥接路径。独立宿主仍仅保存实验副本; 工作台版通过 Lithe 保存真实文件,不能把两者的保存行为混淆。 工作台同步字体大小、字体族、明暗外观、原生配色主题、背景透明状态及折行。 + +macOS Java 导入区默认折叠使用 Monaco 0.55.1 `foldingImportsByDefault`,由宿主显式启用。 +`javaFolding` 桥接复用既有 `AppModel.javaStructure` / Core 的词法结构结果,把零基行号转换为 Monaco +的一基范围;没有新增正则解析器,也不等待 JDTLS 初始化。Core 的 imports 范围要求至少两条导入, +其余类体、方法和注释仅提供手动折叠范围,不默认收起。词法范围的精度沿用现有 Core,若将来需要 +完整语义折叠,应替换为已有 LSP foldingRange 能力,不在前端另做 Java 解析。 +Monaco 自身保存已应用默认折叠的标志和手动折叠状态,随已有标签页/分屏视图状态恢复;不在每次激活 +或输入后执行 foldAll。请求等待已有编辑同步链,原生与前端均拒绝版本、文件位置或模型生命周期已变化 +的结果。真实 WebKit 验证初始导入折叠、类体展开、手动展开后切换标签和源码不变;Swift 检查直接调用 +Core 验证普通/static 导入的范围及单条导入不产生折叠。Windows 入口未启用这个选项。 + 默认字体跟随全局 JetBrains Mono 2.304。WebKit 的网页进程不会继承原生进程的 CoreText 注册,因此 `MonacoWorkbenchAssets` 从安装包现有 `Fonts` 目录只读 提供 TTF;编辑器 HTML 用 `@font-face` 引用同一字型,加载后重新度量 Monaco。 @@ -793,3 +824,33 @@ UTF-16 编辑、保存屏障、Worker 和迟到语义结果;Swift 串行回归 - `macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift` - `macos/Sources/Lithe/Models/Editor/EditorDocument.swift` - `macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift` + + +### 悬停说明显隐回归(macOS) + +对照 Community `cdc10bf305a5ceb5377c1d919de33e5f009cf15d` 的 +`platform/lang-impl/src/com/intellij/codeInsight/navigation/CtrlMouseHandler.kt` 与 +`platform/lang-impl/src/com/intellij/openapi/editor/EditorMouseHoverPopupManager.java`:Command 提示与普通说明 +不是同一个显隐状态。已有普通说明时不新建 Command 提示;松开 Command 只关闭本次 Command 打开的提示。 +命令点击、Esc、编辑、切换模型、视口滚动、编辑器组件失焦和窗口失焦清理提示及未完成的导航悬停结果。 +鼠标进入说明面板、选择文字或点击链接继续交给 Monaco sticky hover 处理,避免用编辑区 mouseleave +无条件关闭交互面板。所有窗口监听随编辑器销毁移除,迟到结果仍需通过 generation/文档快照检查。 +Command 提示采用上游信息标签的 6/6/5/6 内边距,去掉首尾段落额外外边距;外层依旧消费 Lithe 共享 +popup chrome,不复制另一套主题。普通说明维持 500ms 和 Monaco 文档排版。 +真实 WebKit 生命周期回归检查松键、Esc、滚动、失焦、编辑、切换模型、移入提示及普通说明不受松键影响, +并检查取消后的请求不能重开面板。组件回归不等同于完整应用的人工视觉验收。 + + +### 实现选择器的原生弹窗生命周期(macOS) + +`LanguageImplementationChooserView` 原先直接叠在 EditorAreaView 上,既没有原生弹窗的外部点击/Esc +关闭机制,也没有完整焦点归属。现在通过 `litheDropdown` 的持久锚点呈现,关闭 binding 统一调用 +`closeLanguageNavigationResults()`,同时重置导航 operation,迟到结果不能恢复已取消列表。 +选中目标后关闭,切换编辑标签同样关闭;键盘上下选择、Return 确认走已有 dropdown 键盘入口, +搜索输入法组合期间不截获确认键。共享 presenter 负责外部点击、Esc、失焦、屏幕边界和监听清理。 +外框由共享 `litheContextMenuSurface` 唯一绘制;搜索框与结果行分别使用 `LitheSearchField` 和 +`LitheDropdownRowStyle`,去掉独立 7pt 圆角、缩放过渡和至少 220pt 的空白高度,列表按行数收缩。 +依据 IDEA Community 的 `platform/lang-impl/src/com/intellij/codeInsight/navigation/GotoTargetHandler.java` +`showNotEmpty`:用 PopupChooserBuilder 呈现目标列表、选择后导航,并在取消回调中取消后台更新。 +本轮只修复选择器的承载和样式;声明/用法/实现的查询语义分流仍是独立待修问题,不能把布局修复当成语义已对齐。 +验证:原生共享弹窗回归覆盖深浅主题、菜单与自定义内容的 Esc、关闭后重开、点击外部关闭;相关导航测试同时运行。 diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index 1ed8a8646..f978489eb 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -1,3 +1,5 @@ +import { ContentHoverController } from "monaco-editor/esm/vs/editor/contrib/hover/browser/contentHoverController.js"; +import { ClickLinkGesture } from "monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/link/clickLinkGesture.js"; import { runEditorCommand } from "./editor-commands"; import { installWordHighlightLifecycle } from "./word-highlight-lifecycle"; import { IBulkEditService } from "monaco-editor/esm/vs/editor/browser/services/bulkEditService.js"; @@ -31,6 +33,8 @@ const nativeFoldingOffset = nativeGutter.gap * 2 + nativeGutter.icon; export interface WorkbenchHost { request(payload: object): Promise; + hoverDelay?: number; + foldJavaImportsByDefault?: boolean; palette: { defaults: Record }; javaNavigationIcons?: Record<"up-interface" | "up-inheritance" | "down-interface" | "down-inheritance", string>; /** IDEA test-state SVGs for Java Run markers. */ @@ -600,14 +604,74 @@ export function mountWorkbench(host: WorkbenchHost) { }; view.addAction({ id: "lithe.goToDefinition", label: "Go to Definition", keybindings: [monaco.KeyCode.F12], contextMenuGroupId: "navigation", contextMenuOrder: 1, run: () => navigate(view.getPosition()) }); - view.onMouseDown(event => { - const gesture = event.event; - if (!(isMacintosh ? gesture.metaKey : gesture.ctrlKey) || !gesture.leftButton || gesture.altKey || gesture.shiftKey || - event.target.type !== monaco.editor.MouseTargetType.CONTENT_TEXT || !event.target.position) return; - gesture.preventDefault(); - gesture.stopPropagation(); - void navigate(event.target.position); + // Reuse Monaco's modifier/drag/key lifecycle, but keep file ownership, + // virtual sources and navigation history in the native host. Monaco's full + // definition contribution requires loading target files into its model store. + const gesture = new ClickLinkGesture(view); + const links = view.createDecorationsCollection(); + let generation = 0; + let hovered = ""; + let ownsHover = false; + const clear = (hideHint = true) => { + generation++; hovered = ""; links.clear(); + if (hideHint && ownsHover) { + ownsHover = false; + view.getDomNode()?.classList.remove("lithe-command-hover"); + ContentHoverController.get(view)?.hideContentHover(); + } + }; + const dismiss = () => { clear(); ContentHoverController.get(view)?.hideContentHover(); }; + gesture.onCancel(() => clear()); + // Monaco owns movement into the popup, its sticky corridor and link selection. + view.onMouseLeave(() => clear(false)); + view.onDidBlurEditorWidget(dismiss); + view.onDidChangeModel(dismiss); + view.onDidChangeModelContent(dismiss); + view.onDidScrollChange(event => { if (event.scrollTopChanged || event.scrollLeftChanged) dismiss(); }); + view.onKeyDown(event => { if (event.keyCode === monaco.KeyCode.Escape) dismiss(); }); + window.addEventListener("blur", dismiss); + gesture.onMouseMoveOrRelevantKeyDown(async ([mouse, key]) => { + const position = mouse.target.position; + const pair = [...entries].find(([, entry]) => entry.model === view.getModel()); + if (!(mouse.hasTriggerModifier || key?.keyCodeIsTriggerKey) || mouse.hasSideBySideModifier) { clear(); return; } + if (mouse.target.element?.closest(".monaco-hover,.monaco-resizable-hover")) return; + if (!pair || !position || mouse.target.type !== monaco.editor.MouseTargetType.CONTENT_TEXT) { clear(false); return; } + const [id, entry] = pair; + const word = entry.model.getWordAtPosition(position); + if (!word) { clear(); return; } + const identity = `${id}:${entry.model.getVersionId()}:${position.lineNumber}:${word.startColumn}:${word.endColumn}`; + if (hovered === identity) return; + clear(); + hovered = identity; + const request = generation, current = documentCheckpoint(id, entry); + await entry.chain; + if (request !== generation || !current() || view.getModel() !== entry.model) return; + const reply = await languageRequest({ type: "definitionHover", id, revision: entry.revision, + line: position.lineNumber - 1, column: position.column - 1 }); + if (request !== generation || !current() || view.getModel() !== entry.model) return; + if (reply.available === true) { + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); + links.set([{ range, options: { inlineClassName: "goto-definition-link" } }]); + // IDEA's Command hint has no extra timer after semantic resolution. + const hover = ContentHoverController.get(view); + if (hover && !hover.isHoverVisible) { + ownsHover = true; + view.getDomNode()?.classList.add("lithe-command-hover"); + hover.showContentHover(range, 1 /* Immediate */, 0 /* Mouse */, false); + } + } else { + // An unavailable server is transient; the next gesture must be able to retry. + hovered = ""; + } + }); + gesture.onExecute(mouse => { + if (mouse.isLeftClick && mouse.hasTriggerModifier && !mouse.hasSideBySideModifier && + mouse.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT) { + clear(); + void navigate(mouse.target.position); + } }); + view.onDidDispose(() => { clear(); window.removeEventListener("blur", dismiss); gesture.dispose(); }); } function attachRunInteractions(view: monaco.editor.IStandaloneCodeEditor) { @@ -1256,6 +1320,8 @@ export function mountWorkbench(host: WorkbenchHost) { // Full-width punctuation is normal in Chinese prose; Monaco's confusable-character // warning misfires there and its "Adjust settings" link has no target in embedded Monaco. unicodeHighlight: { ambiguousCharacters: false }, + foldingImportsByDefault: host.foldJavaImportsByDefault ?? false, + ...(host.hoverDelay !== undefined ? { hover: { delay: host.hoverDelay } } : {}), }; editor = monaco.editor.create(document.querySelector("#editor") as HTMLElement, displayOptions); installWordHighlightLifecycle(editor); @@ -1333,6 +1399,24 @@ export function mountWorkbench(host: WorkbenchHost) { .monaco-editor .lithe-debug-value{color:var(--vscode-editor-inlineValuesForeground,#888);background:var(--vscode-editor-inlineValuesBackground,transparent);font-style:italic}`; document.head.append(debugStyle); textmate = await installJavaTextMate(); + if (host.foldJavaImportsByDefault) monaco.languages.registerFoldingRangeProvider("java", { + async provideFoldingRanges(model, _context, token) { + const pair = [...entries].find(([, entry]) => entry.model === model); + if (!pair) return null; + const [id, entry] = pair, current = documentCheckpoint(id, entry); + await entry.chain; + if (!current() || token.isCancellationRequested) return null; + const reply = await languageRequest({ type: "javaFolding", id, revision: entry.revision }); + if (!current() || token.isCancellationRequested || reply.cancelled) return null; + return (reply.ranges ?? []).filter((range: any) => Number.isInteger(range.start) && + Number.isInteger(range.end) && range.start >= 1 && range.end > range.start && + range.end <= model.getLineCount()).map((range: any) => ({ + start: range.start, end: range.end, + kind: range.kind === "imports" ? monaco.languages.FoldingRangeKind.Imports : + range.kind === "comment" ? monaco.languages.FoldingRangeKind.Comment : undefined, + })); + }, + }); monaco.languages.registerDocumentSemanticTokensProvider("java", { getLegend: () => MONACO_SEMANTIC_TOKEN_LEGEND, onDidChange: semanticChanges.event, diff --git a/macos/EditorFrontend/index.html b/macos/EditorFrontend/index.html index 659df321b..4a5f9fb3b 100644 --- a/macos/EditorFrontend/index.html +++ b/macos/EditorFrontend/index.html @@ -2,7 +2,9 @@ -