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 cd9ccc870b..ff523a0e84 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,31 @@ 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` 的剩余延迟计算。 + Community 的 `CtrlMouseHandler.kt` 在语义结果返回后立即显示提示。根据用户对频繁弹出遮挡代码的反馈, + Lithe 有意改用 Monaco Delayed 模式,沿用 macOS 普通文档的 500ms 延迟;候选词的手形、变色与下划线同步显示, + 说明面板仍在语义确认后启动延迟,避免为不可导航候选请求说明。反馈是候选状态,查询失败或无目标时撤销; + 候选过滤复用 Monaco/TextMate 的 standard token type,注释、字符串和正则正文不显示导航反馈, + Command 点击也走相同过滤;显式 F12 保留语言服务原有语义。词法状态尚未就绪且需大段同步解析时暂不提示, + 避免为高频鼠标移动扫描整个文件,不另写正则猜注释。 + 点击仍通过真实导航解析,不把候选装饰当作已确认目标。复用 Monaco 的延迟、取消、内容、定位及键盘可访问性,不另建计时器;单目标导航会收起旧结果面板。Windows 宿主没有使用该 macOS 入口,未改其实现。 + - Markdown split 将原有 `MarkdownScrollPosition` binding 接回 Monaco,沿用来源与 revision 防回环; 只在该视图启用滚动消息,33 ms 合并连续输入,切换文档或接收预览滚动时撤销旧回传。 滚动消息不改文本或 undo;普通源码编辑不向原生工作台持续发送滚动状态。 @@ -640,6 +665,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 +829,81 @@ 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 调用 `dismissImplementationChooser()`,仅在选择器仍显示(用户取消)时转交 +`closeLanguageNavigationResults()` 重置 operation;选中目标已先隐藏列表,随后到达的关闭回调不能取消正在加载的虚拟源码。插件关闭、切换工作区等强制清理仍无条件重置 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、关闭后重开、点击外部关闭;相关导航测试同时运行。 + + +Command 悬停的光标反馈不再依赖异步返回后才创建的文字 span:候选词命中时立即在所属编辑视图上设置 +指针状态,只覆盖代码行及其子元素;候选词同时显示链接色与下划线,无目标回复再撤销。文字重绘与慢语言服务回复不会 +把候选词重新变回编辑光标。无目标/失败的结果在当前手势内保持,防止逐像素移动反复请求造成小手与 +I-beam 交替;移出词、松键或文档/视图生命周期变化后可以重新查询。取消同时移除视图指针状态,迟到 +响应不得恢复。原生 WebKit 回归用显式请求到达/释放闸门模拟慢服务,检查等待期间、重绘、负结果及松键后 +的真实 computed cursor,不使用时间睡眠。 + + +Java 首帧通过统一 `attachModel` 等待 Monaco `FoldingController.getFoldingModel()` 后发布:切换到 Java +模型前仅暂时隐藏该编辑视图,完成 setModel、保存状态恢复及初始折叠后同步 render,再恢复原 opacity/inert。容器用 opacity 隐藏,避免 Monaco 自身重绘覆盖内部 visibility;准备期间 inert 禁止输入。 +主编辑器与分屏复用该顺序,同一模型更新和非 Java 文件不等待;不使用固定 sleep,也不重复实现折叠规则。 +这消除了先呈现全部 imports、随后异步收起的布局跳变,代价是首次内容呈现等待已有折叠计算;原生请求 +仍受现有桥接超时控制,finally 保证错误路径恢复可见性。真实 WebKit 通过折叠 provider 的显式闸门验证 +等待期间不可见,完成后的首个可见状态已经折叠,并继续覆盖手动展开状态的标签恢复。 + + +Java TextMate 的 `punctuation.definition.comment` 是注释边界,不是普通代码标点。 +共享 `tokenRole` 在通用 punctuation 之前将其映射为 comment,使 `//`、`/*`、`*/` 与正文 +使用相同主题色,也保持其注释 token 类型;除法运算符继续使用普通标点规则。 +真实 WebKit 在深浅主题验证行注释、块注释和文档注释的前景色及类型,不另外写 CSS 颜色覆盖。 + + +### 定义跳转按需启用(macOS) + +Java Support 插件清单提供默认关闭的“启用类和方法跳转(Command+点击 / F12)”,通过 settings 3 的 +editorNavigation 声明将设置、默认值及语言归属一起交给插件。唯一值源是现有插件设置存储;编辑器全局 +开关已删除。语义解析仍交给插件中的 JDTLS,宿主只实现声明触发的通用手势,不新增 Java 符号索引。既有用法、继承标记、 +诊断、补全与普通文档悬停属于独立能力,不随此开关停服,也不把共享语言进程的内存承诺为零。 + +启用时为每个编辑视图创建 Monaco DisposableStore(上游提供的资源集合);关闭时释放整个集合中的手势、 +动作和事件订阅,清理链接装饰、指针与自己拥有的延迟提示。现有分屏同步更新,新分屏读取当前开关。 +原生入口同时拒绝关闭期间的定义请求,重置结果面板的 operation;菜单动作也禁用。不能只在鼠标回调中 +加一个布尔判断,因为那样仍会保留专属监听。复用 Monaco 的资源集合即可,不需要再造插件加载器。 + +关闭会使已发出的请求失去发布资格,不再产生新定义请求;已经进入共享语言服务的请求仍按现有的完成/超时 +生命周期结束。本轮没有扩展语言服务协议为请求级取消,也不会为取消一个跳转而杀掉共享 JDTLS。 +因此这里保证的是关闭后不再开展跳转专属活动,而非关闭瞬间进程 RSS 必须下降或所有在途请求物理中断。 + +验证:真实 WebKit 测试控制慢请求返回顺序,覆盖关闭、重开、旧回复、重复开关无重复点击和现存/新建分屏; +PluginSettingsTests 验证插件声明、默认关闭、持久化、跨项目通知、禁用撤销、关闭面板和旧操作失效,以及项目模型/插件管理器不被通知订阅保留。未声明开关的其他语言保留既有点击与 F12,不增加悬停查询;WebKit 覆盖 XML 属性、配置文件、其他语言,以及 100 轮开关后监听归零和模型数量稳定。 + + +导航策略使用每份文档的插件语言 ID,而非 Monaco 的语法 ID:`bash` 对应 `shell`、`properties` 对应 `ini`, +不同插件语言也可共用同一种语法。模型保留插件身份;省略语言的同文档更新保持该身份,显式改变身份时即使 +语法不变也释放旧手势并使在途语义结果过期。真实 WebKit 回归覆盖别名的开关及悬停、共用语法的主视图/分屏独立策略、 +同模型身份切换。Swift 受控回复回归覆盖选择实现 → 关闭回调 → 延迟虚拟源码到达,以及加载期间关闭功能、用户取消。 diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index 748d3ef77a..334a0e2460 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -13,6 +13,7 @@ ## 决策 - **远程实现只保留一个 crate**:`rust/lithe-remote/` 内部分为 `client`、`protocol`、`server` 三个模块,共用一个版本和依赖入口。协议类型始终可用,Cargo feature(构建时选择哪些代码和依赖)默认只启用 `client`;Core 的正常依赖明确只选客户端,Linux/macOS 的真实服务测试额外启用 `server`。打包必须用 `--no-default-features --features server --bin lithe-remote-server`,不能让工作区其他包的 feature 合并把客户端带入远端发布物。可执行文件仍叫 `lithe-remote-server`,安装路径、握手和协议版本不变;协议单独构建不需要 SHA-256 或主机适配依赖,客户端不引入 `trash` 或服务端平台代码。 +- **守护进程启动必须有确定结果**:并发连接可能各自启动候选进程。代理必须等自己启动的候选已经写入会话锁的 PID,或已退出,才连接 socket;不能因为别的候选已发布 socket 就提前握手,否则显式关闭后,迟到的候选还会接管锁并留下新进程。复用已有锁文件和子进程退出状态,不增加协议、目录或长期监听;等待沿用启动期限,失败时终止并回收本次启动的候选,成功的守护进程继续跨断线保留。 - **占位根目录**:`MacRemoteWorkspaceRoots` 在 `Application Support/Lithe/RemoteWorkspaces/<连接 id>/<远程路径哈希>/<文件夹名>` 创建一个空目录,用它作为工作区根。目录名是远程文件夹名,所以项目名显示正确。它不在 app bundle 里,不影响签名和 Sparkle 增量包;里面没有用户数据,跨启动保留,让按根目录保存的项目状态得以延续。 - **注册表**:`RemoteWorkspaceRegistry` 记录每个占位根对应的连接、远程路径和 Core 会话 id。打开远程项目时登记,同一个根再次打开时,旧会话会被替换并断开。 - **路由**:组合根(`MacServiceContainer`,负责组装依赖的地方)把本地实现包进 `RemoteRoutingWorkspaceOperations` 和 `RemoteRoutingFileOperations`。路由层对端口里的**每个**方法都显式转发:如果只覆盖一部分,没覆盖的方法会悄悄走协议扩展里的默认实现,本地文件的行为就变了。 @@ -48,6 +49,10 @@ ## 考虑过的备选方案 +### 看到 socket 就立即连接,或延长退出等待 + +已有 socket 只证明某个守护进程已就绪,不证明本次启动的候选已退出。迟到候选能在显式关闭之后创建新会话,延长测试等待无法消除这条时序。代理等待自己的候选明确去留会增加少量有界轮询,但保证已完成连接不再遗留尚未作出启动决定的候选。 + ### 保留三个 crate,或合并后默认编译全部模块 三者总是随同一份协议一起维护,没有独立发布需求,保留三个包只会增加依赖入口和路径维护。全部默认编译又会把只适用于 Linux/macOS 的服务端带进 Windows 客户端。采用单包加可选 feature,减少顶层模块而保留平台和依赖边界;代价是验证必须覆盖纯协议、纯客户端、纯服务端和两端一起启用四种组合,并继续检查远端二进制大小。 @@ -82,6 +87,8 @@ ## 验证 +- `./scripts/remote/verify-remote-workspace.sh --tests` 覆盖守护进程启动与退出:用未释放标准输入的候选进程和已过期截止时间,确定性验证已有 socket 不能绕过启动决定,并验证锁归属本候选、候选已退出两条成功路径;并发连接集成测试确认显式关闭后锁释放。Linux CI 与 macOS 本机分别覆盖各自适配器,本机通过不能代替 Linux 验证。 + ```bash ./scripts/macos/test/test-macos.sh --filter RemoteConnections ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemoteWorkspaceRoutingTests|RemoteDirectoryObservationTests' diff --git a/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md b/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md index b07c0b10b3..9cb58d6778 100644 --- a/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md +++ b/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md @@ -91,3 +91,13 @@ cargo test --manifest-path rust/Cargo.toml -p lithe-core remote:: - `rust/lithe-core/src/remote/` - `rust/lithe-remote/src/client/launch.rs` - `scripts/remote/build-remote-servers.sh` + + +### 远程开发主面板的统一呈现 + +主入口从 SwiftUI sheet 改为已有 `LitheCenteredPopup` 的有标题表单模式,与打开项目和 Git 表单 +共用窗口外框、居中定位、父窗口事件隔离及 `animationBehavior = .none`;不再使用系统 sheet 展开动作。 +取消与保存成功通过显式回调复位 `isRemoteConnectionsPresented`,原生窗口关闭走同一 binding; +内容消失仍调用 `feature.close()` 清理连接任务。输入框、按钮样式、业务动作与面板尺寸保持原实现, +认证提示与目录浏览的内部 sheet 不在本次迁移范围内。验证复用 `LitheCenteredPopupTests` 的打开、 +父窗口跟随、无动画、关闭清理与嵌套选择器场景,以及远程连接/路由回归;真实 SSH 登录仍需独立验收。 diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 9858036fe9..e2d6b8f406 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -957,7 +957,7 @@ Medium字重的普通按钮会变为欢迎页的28px、Regular;不再以 xs 以及原生 Go To Line、Search Everywhere 的键盘与点击回归。 按钮同时保留内容所需的水平宽度(`fixedSize(horizontal: true, vertical: false)`), -避免搜索区与按钮组分配空间时把长文案压缩成省略号;搜索区仍可随可用空间收缩。 +避免搜索区与按钮组分配空间时把长文案压缩成省略号;欢迎页搜索区保留 180–220pt,次要操作放不下时收入更多菜单。 同 revision 的 `ProjectsTabFactory.kt` 为搜索输入指定 `AllIcons.Actions.Search`; `RecentProjectFilteringTree.kt.installSearchField` 去掉输入边框,并通过 `emptyText` @@ -2059,3 +2059,27 @@ PluginNativeUITests验证禁用说明可点击打开真正的公共弹窗。 有空间时显示完整路径,只有实际宽度不足才从中间省略;名称、分支和第三行状态的位置不因此改变。 路径截断时复用 Stash 文件列表的 AppKit 原生行内展开(expansion tooltip),只在路径行内悬停展开完整路径;不使用多行 help 气泡,也不重建整份原生列表。 验证使用 `GitWorktreeAppearanceTests`、`CommitActionButtonStyleTests` 及真实示例项目的深浅主题、键盘焦点和窄宽布局。 + + +欢迎页窄窗口保留单行与原有顶部高度。搜索框保持 180–220pt,新建项目和打开常驻; +`ViewThatFits` 根据次要按钮的实际理想宽度,在完整按钮与 `LitheMenu` 更多入口之间切换。 +克隆仓库、远程开发复用原动作及可用条件,菜单继续使用统一呈现器;不改变窗口最小尺寸, +不缩小文字,也不换行。搜索框位于切换容器外,避免缩放时重建输入控件。 +`RecentProjectBranchMetadataTests` 在深浅主题下按 800→1100→800pt 验证窄窗口更多入口、 +宽窗口完整操作、搜索区域宽度、单行对齐和窗口边界。原生截图仅验证布局;测试宿主的图标 +资源缺失不能视为产品资源视觉验收通过。 + + +欢迎页更多入口使用本地化 `More` 文字(中文为“更多”),继续复用文字动作按钮;不再使用省略号图标。 +克隆仓库面板删除自绘输入边框、圆角与字号,三项输入统一使用 `LitheTextField`,字段标签和间距由 +`LitheFormField` 提供。目录选择按钮放在输入框右侧,不嵌套于输入边框内。保留原克隆任务、禁用条件、目录选择及文件夹名建议。面板保持 560pt 宽, +高度随统一输入组件和错误提示增长,避免固定 360pt 裁剪表单。原生深浅测试检查输入区域、字体及 +输入 URL 后自动生成文件夹名;不执行真实网络克隆。 + + +克隆仓库的实际打开入口也必须使用 `LitheCenteredPopup(dialogTitle:)`,而不仅统一内容控件。 +`RootView` 已移除该入口的系统 `.sheet`,删除内容自绘标题行,由共享有标题面板负责外框、标题、 +关闭控件、居中和无动画呈现。取消与克隆成功复位工作台呈现状态;克隆期间禁止手动关闭, +父窗口退出仍按共享生命周期清理。使用 `resizesToContent` 让错误提示出现时自然增高。 +`CloneRepositoryStyleTests` 将实际克隆内容放进共享 presenter,检查非 sheet、无动画、原生标题栏、 +关闭状态、深浅输入布局及文件夹建议;`LitheCenteredPopupTests` 覆盖忙碌与父窗口清理边界。 diff --git a/docs/assets/java-support-navigation/clone-after.png b/docs/assets/java-support-navigation/clone-after.png new file mode 100644 index 0000000000..2ace7c5b3c Binary files /dev/null and b/docs/assets/java-support-navigation/clone-after.png differ diff --git a/docs/assets/java-support-navigation/clone-before.png b/docs/assets/java-support-navigation/clone-before.png new file mode 100644 index 0000000000..7ede91696d Binary files /dev/null and b/docs/assets/java-support-navigation/clone-before.png differ diff --git a/docs/assets/java-support-navigation/java-keyword-navigation-setting.png b/docs/assets/java-support-navigation/java-keyword-navigation-setting.png new file mode 100644 index 0000000000..d2a2b94c8d Binary files /dev/null and b/docs/assets/java-support-navigation/java-keyword-navigation-setting.png differ diff --git a/docs/assets/java-support-navigation/remote-after.png b/docs/assets/java-support-navigation/remote-after.png new file mode 100644 index 0000000000..418143ff0d Binary files /dev/null and b/docs/assets/java-support-navigation/remote-after.png differ diff --git a/docs/assets/java-support-navigation/welcome-after.png b/docs/assets/java-support-navigation/welcome-after.png new file mode 100644 index 0000000000..624b7fb754 Binary files /dev/null and b/docs/assets/java-support-navigation/welcome-after.png differ diff --git a/docs/assets/java-support-navigation/welcome-before.png b/docs/assets/java-support-navigation/welcome-before.png new file mode 100644 index 0000000000..e478596e66 Binary files /dev/null and b/docs/assets/java-support-navigation/welcome-before.png differ diff --git a/frontend/editor/src/textmate-grammar.ts b/frontend/editor/src/textmate-grammar.ts index e81cf7b6cf..cabb528270 100644 --- a/frontend/editor/src/textmate-grammar.ts +++ b/frontend/editor/src/textmate-grammar.ts @@ -18,7 +18,7 @@ export async function createJavaGrammar(wasm: ArrayBuffer, grammarText: string) export function tokenRole(scopes: string[]): string { for (let i = scopes.length - 1; i >= 0; i--) { const scope = scopes[i]; - if (/^comment/.test(scope)) return "comment"; + if (/^comment|^punctuation\.definition\.comment/.test(scope)) return "comment"; if (/^string/.test(scope)) return "string"; if (/^constant.numeric/.test(scope)) return "number"; if (/^constant.language/.test(scope)) return "keyword"; diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index 4993aa35bb..b91e85726b 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -1,3 +1,7 @@ +import { DisposableStore, toDisposable } from "monaco-editor/esm/vs/base/common/lifecycle.js"; +import { FoldingController } from "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js"; +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"; @@ -7,6 +11,7 @@ import { mapCompletionKind } from "./completion-kind"; import { mountDiffReview, type DiffReviewInput, type ReviewSelection } from "./diff-review"; import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js"; import "monaco-editor/esm/vs/editor/editor.all.js"; +import { language as javaLanguage } from "monaco-editor/esm/vs/basic-languages/java/java.js"; import { ensureMonacoLanguageTokenizer } from "./language-contributions"; import { toMonacoLanguageId } from "./language"; import { SourceText } from "./source-text"; @@ -31,6 +36,10 @@ const nativeFoldingOffset = nativeGutter.gap * 2 + nativeGutter.icon; export interface WorkbenchHost { request(payload: object): Promise; + hoverDelay?: number; + /** Document language IDs whose host supports semantic link availability. */ + definitionLinkLanguages?: readonly string[]; + foldJavaImportsByDefault?: boolean; palette: { defaults: Record }; javaNavigationIcons?: Record<"up-interface" | "up-inheritance" | "down-interface" | "down-inheritance", string>; /** IDEA test-state SVGs for Java Run markers. */ @@ -60,7 +69,7 @@ export function mountWorkbench(host: WorkbenchHost) { type GitMarker = { id: string; line: number; kind: "added" | "modified" | "deleted"; stage: boolean; unstage: boolean; discard: boolean }; type BlameLine = { line: number; commit: string; author: string; date: string }; type JavaMarker = { id: string; line: number; direction: "up" | "down"; relation?: "interface" | "inheritance" }; - type Entry = { source: SourceText; model: monaco.editor.ITextModel; release: () => void; revision: number; chain: Promise; readonly: boolean; frozen: boolean; freezeOperation?: string; pendingEdits: number; state: monaco.editor.ICodeEditorViewState | null; debugDecorations?: string[]; debugPaused?: boolean; canToggleBreakpoints?: boolean; debugGeneration?: number; closingHolds?: number; filename?: string; locationRevision: number; contextGeneration: number }; + type Entry = { languageID: string; source: SourceText; model: monaco.editor.ITextModel; release: () => void; revision: number; chain: Promise; readonly: boolean; frozen: boolean; freezeOperation?: string; pendingEdits: number; state: monaco.editor.ICodeEditorViewState | null; debugDecorations?: string[]; debugPaused?: boolean; canToggleBreakpoints?: boolean; debugGeneration?: number; closingHolds?: number; filename?: string; locationRevision: number; contextGeneration: number }; const entries = new Map(); const workers: Worker[] = []; const closingOperations = new Map(); @@ -112,6 +121,8 @@ export function mountWorkbench(host: WorkbenchHost) { let suspendedViews: { role: string; id: string | undefined; state: monaco.editor.ICodeEditorViewState | null; find: FindSnapshot | undefined; focused: boolean }[] | undefined; let displayOptions: monaco.editor.IStandaloneEditorConstructionOptions = {}; + // Host setting: skip Java keyword definition lookups. Defaults on, like the native host. + let javaKeywordNavigationFilter = true; // Keep a rendered, hit-testable gutter on lines without breakpoints too. // Monaco Off suppresses lineNumberClassName decorations along with the digits. const hiddenLineNumbers = () => "\u200b"; @@ -140,6 +151,7 @@ export function mountWorkbench(host: WorkbenchHost) { } let updating = false; let failed = false; + const definitionNavigation = new Map(); let activation: Promise = Promise.resolve(); let textmate: Awaited>; const semanticChanges = new Emitter(); @@ -306,7 +318,9 @@ export function mountWorkbench(host: WorkbenchHost) { async function prepareEntry(payload: any): Promise { let entry = entries.get(payload.id); - const language = payload.language ? toMonacoLanguageId(payload.language) : languageForFilename(payload.filename); + const languageID = payload.language || (entry && (payload.filename === undefined || payload.filename === entry.filename) + ? entry.languageID : languageForFilename(payload.filename)); + const language = toMonacoLanguageId(languageID); if (!entry) { if (typeof payload.text !== "string") throw new Error("New model requires document text"); await ensureMonacoLanguageTokenizer(language); @@ -319,7 +333,7 @@ export function mountWorkbench(host: WorkbenchHost) { const model = acquired.model; // Monaco owns normalized editing coordinates; the source mirror preserves disk newlines. model.setEOL(monaco.editor.EndOfLineSequence.LF); - entry = { source: new SourceText(payload.text, model.getAlternativeVersionId()), model, release: acquired.release, revision: payload.revision, chain: Promise.resolve(), readonly: payload.readonly, frozen: false, pendingEdits: 0, state: null, filename: payload.filename, locationRevision: payload.locationRevision ?? 0, contextGeneration: 0 }; + entry = { languageID, source: new SourceText(payload.text, model.getAlternativeVersionId()), model, release: acquired.release, revision: payload.revision, chain: Promise.resolve(), readonly: payload.readonly, frozen: false, pendingEdits: 0, state: null, filename: payload.filename, locationRevision: payload.locationRevision ?? 0, contextGeneration: 0 }; entries.set(payload.id, entry); const owned = entry; model.onDidChangeContent(event => { @@ -338,8 +352,9 @@ export function mountWorkbench(host: WorkbenchHost) { }); }); } else { - const languageChanged = (payload.language || payload.filename) && entry.model.getLanguageId() !== language; - if (languageChanged || (payload.filename !== undefined && payload.filename !== entry.filename) || + const identityChanged = entry.languageID !== languageID; + const languageChanged = entry.model.getLanguageId() !== language; + if (identityChanged || languageChanged || (payload.filename !== undefined && payload.filename !== entry.filename) || (payload.locationRevision !== undefined && payload.locationRevision !== entry.locationRevision)) { // Invalidate before awaiting syntax loading. Rename away and back must // not revive an older request, even when its text version is unchanged. @@ -347,6 +362,12 @@ export function mountWorkbench(host: WorkbenchHost) { if (payload.filename !== undefined) entry.filename = payload.filename; if (payload.locationRevision !== undefined) entry.locationRevision = payload.locationRevision; } + if (identityChanged) { + entry.languageID = languageID; + for (const view of allEditors()) { + if (view.getModel() === entry.model) syncDefinitionNavigation(view); + } + } if (languageChanged) { await ensureMonacoLanguageTokenizer(language); if (entries.get(payload.id) !== entry || entry.model.isDisposed()) throw new CancellationError(); @@ -361,6 +382,27 @@ export function mountWorkbench(host: WorkbenchHost) { return entry; } + async function attachModel(view: monaco.editor.IStandaloneCodeEditor, entry: Entry, + state: monaco.editor.ICodeEditorViewState | null | undefined) { + const node = view.getContainerDomNode(); + const prepareFolds = host.foldJavaImportsByDefault && entry.model.getLanguageId() === "java" + && view.getModel() !== entry.model; + const opacity = node.style.opacity, inert = node.inert; + if (prepareFolds) { node.style.opacity = "0"; node.inert = true; } + try { + view.setModel(entry.model); + if (state) view.restoreViewState(state); + // Monaco applies default import folds and saved fold state asynchronously. + // Publish the first frame only after that same upstream operation completes. + if (prepareFolds) await FoldingController.get(view)?.getFoldingModel(); + } finally { + if (prepareFolds) { + if (view.getModel() === entry.model && !entry.model.isDisposed()) view.render(true); + node.style.opacity = opacity; node.inert = inert; + } + } + } + async function activate(payload: any) { const started = performance.now(); const previous = active && entries.get(active); @@ -372,9 +414,8 @@ export function mountWorkbench(host: WorkbenchHost) { const modelMs = performance.now() - modelStarted; const attachStarted = performance.now(); active = payload.id; - editor.setModel(entry.model); + await attachModel(editor, entry, entry.state); scheduleNavigation(payload.id, entry); - if (entry.state) editor.restoreViewState(entry.state); editor.updateOptions({ readOnly: entry.readonly || !!entry.closingHolds || failed }); if (payload.focus) editor.focus(); const metrics = { reused, modelMs, attachMs: performance.now() - attachStarted, @@ -599,26 +640,144 @@ export function mountWorkbench(host: WorkbenchHost) { node.addEventListener("paste", paste, true); view.onDidDispose(() => node.removeEventListener("paste", paste, true)); } - function attachDefinitionNavigation(view: monaco.editor.IStandaloneCodeEditor) { + function syncDefinitionNavigation(view: monaco.editor.IStandaloneCodeEditor) { + // Plugin identity belongs to the document; several identities can share + // one Monaco tokenizer (for example properties/ini or bash/shell). + const languageID = [...entries.values()].find(entry => entry.model === view.getModel())?.languageID; + const existing = definitionNavigation.get(view); + if (existing && (existing.languageID !== languageID || existing.model !== view.getModel())) { + existing.lifetime.dispose(); definitionNavigation.delete(view); + } + if (languageID && !definitionNavigation.has(view)) { + definitionNavigation.set(view, { languageID, model: view.getModel()!, lifetime: attachDefinitionNavigation(view, host.definitionLinkLanguages?.includes(languageID) === true) }); + } + } + function attachDefinitionNavigation(view: monaco.editor.IStandaloneCodeEditor, managed: boolean) { + const lifetime = new DisposableStore(); + // Monaco removes its model view before onDidChangeModel; retain the owned + // node until disposal so cleanup can still remove pointer/hint classes. + const node = view.getDomNode(); + // Keywords are "code" to Monaco's standard token types, but they never have a + // definition. Filtering them here avoids a language-server round trip that can + // take seconds for keywords such as `static` or `switch`. + // `this` and `super` also name constructor calls, which have definitions, so they stay navigable. + // Contextual keywords can also be legal identifiers; let the server resolve them. + const navigableJavaWords = new Set(["this", "super", "yield", "record", "sealed", "permits", "non-sealed"]); + const javaKeywords = new Set(javaLanguage.keywords.filter(word => !navigableJavaWords.has(word))); + const isKeywordPosition = (position: monaco.IPosition | null) => { + const model = view.getModel(); + if (!javaKeywordNavigationFilter || !position || model?.getLanguageId() !== "java") return false; + const word = model.getWordAtPosition(position)?.word; + return word !== undefined && javaKeywords.has(word); + }; + const isCodePosition = (position: monaco.IPosition | null) => { + const tokens = (view.getModel() as any)?.tokenization; + if (isKeywordPosition(position)) return false; + // Reuse Monaco/TextMate lexical state, including multiline comments. Do not + // synchronously tokenize a large prefix just to respond to mouse movement. + if (!position || !tokens?.isCheapToTokenize(position.lineNumber)) return false; + tokens.forceTokenization(position.lineNumber); + const line = tokens.getLineTokens(position.lineNumber); + return line.getStandardTokenType(line.findTokenIndexAtOffset(position.column - 1)) === 0 /* Other (code) */; + }; const navigate = async (position: monaco.IPosition | null) => { const pair = [...entries].find(([, entry]) => entry.model === view.getModel()); - if (!pair || !position) return; + if (!pair || !position || lifetime.isDisposed || isKeywordPosition(position)) return; const [id, entry] = pair, current = documentCheckpoint(id, entry); await entry.chain; - if (!current() || view.getModel() !== entry.model) return; + if (lifetime.isDisposed || !current() || view.getModel() !== entry.model) return; await languageRequest({ type: "definition", id, revision: entry.revision, line: position.lineNumber - 1, column: position.column - 1 }); }; - 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); - }); + lifetime.add(view.addAction({ id: "lithe.goToDefinition", label: "Go to Definition", keybindings: [monaco.KeyCode.F12], + contextMenuGroupId: "navigation", contextMenuOrder: 1, run: () => navigate(view.getPosition()) })); + if (!managed) { + // Preserve existing navigation for languages without semantic link feedback, + // including XML attribute/configuration links. Do not add hover queries. + lifetime.add(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); + })); + return lifetime; + } + // 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 = lifetime.add(new ClickLinkGesture(view)); + const links = view.createDecorationsCollection(); + let generation = 0; + let hovered = ""; + let ownsHover = false; + const clear = (hideHint = true) => { + generation++; hovered = ""; links.clear(); + node?.classList.remove("lithe-definition-pointer"); + if (ownsHover && (hideHint || !ContentHoverController.get(view)?.isHoverVisible)) { + ownsHover = false; + node?.classList.remove("lithe-command-hover"); + ContentHoverController.get(view)?.hideContentHover(); + } + }; + const dismiss = () => { clear(); ContentHoverController.get(view)?.hideContentHover(); }; + lifetime.add(gesture.onCancel(() => clear())); + // Monaco owns movement into the popup, its sticky corridor and link selection. + lifetime.add(view.onMouseLeave(() => clear(false))); + lifetime.add(view.onDidBlurEditorWidget(dismiss)); + lifetime.add(view.onDidChangeModel(dismiss)); + lifetime.add(view.onDidChangeModelContent(dismiss)); + lifetime.add(view.onDidScrollChange(event => { if (event.scrollTopChanged || event.scrollLeftChanged) dismiss(); })); + lifetime.add(view.onKeyDown(event => { if (event.keyCode === monaco.KeyCode.Escape) dismiss(); })); + window.addEventListener("blur", dismiss); + lifetime.add(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 || !isCodePosition(position)) { clear(); return; } + const identity = `${id}:${entry.model.getVersionId()}:${position.lineNumber}:${word.startColumn}:${word.endColumn}`; + if (hovered === identity) return; + clear(); + hovered = identity; + // Candidate feedback must not wait for the native semantic round trip. + // Availability validates this presentation; clicking still resolves real targets. + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn); + node?.classList.add("lithe-definition-pointer"); + links.set([{ range, options: { inlineClassName: "goto-definition-link" } }]); + 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 hover = ContentHoverController.get(view); + if (hover && !hover.isHoverVisible) { + ownsHover = true; + node?.classList.add("lithe-command-hover"); + // Documentation remains delayed independently of immediate link feedback. + hover.showContentHover(range, 0 /* Delayed */, 0 /* Mouse */, false); + } + } else { + clear(); + // Do not restart candidate feedback on each pixel of a rejected word. + // A new gesture may retry a transient miss. + hovered = identity; + } + })); + lifetime.add(gesture.onExecute(mouse => { + if (mouse.isLeftClick && mouse.hasTriggerModifier && !mouse.hasSideBySideModifier && + mouse.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT && isCodePosition(mouse.target.position)) { + clear(); + void navigate(mouse.target.position); + } + })); + lifetime.add(toDisposable(() => { clear(); window.removeEventListener("blur", dismiss); })); + return lifetime; } function attachRunInteractions(view: monaco.editor.IStandaloneCodeEditor) { @@ -759,7 +918,10 @@ export function mountWorkbench(host: WorkbenchHost) { } attachGitInteractions(view); attachImagePaste(view); - attachDefinitionNavigation(view); + syncDefinitionNavigation(view); + view.onDidChangeModel(() => syncDefinitionNavigation(view)); + view.onDidChangeModelLanguage(() => syncDefinitionNavigation(view)); + view.onDidDispose(() => { definitionNavigation.get(view)?.lifetime.dispose(); definitionNavigation.delete(view); }); attachRunInteractions(view); debugRunContexts.set(view, view.createContextKey("litheCanRunToCursor", false)); function request(type: "toggleBreakpoint" | "editBreakpoint" | "runToCursor", line: number, column = 1) { @@ -980,9 +1142,7 @@ export function mountWorkbench(host: WorkbenchHost) { const state = surface.editor.saveViewState(); if (state) surface.states.set(surface.id, state); surface.id = payload.id; - surface.editor.setModel(entry.model); - const retained = surface.states.get(payload.id); - if (retained) surface.editor.restoreViewState(retained); + await attachModel(surface.editor, entry, surface.states.get(payload.id)); } applyReadOnly(entry); if (payload.focus) surface.editor.focus(); @@ -1080,6 +1240,7 @@ export function mountWorkbench(host: WorkbenchHost) { const theme = payload.theme as WorkbenchThemeInput | undefined; const name = theme ? defineWorkbenchTheme(theme, host.palette) : payload.dark ? "lithe-dark" : "lithe-light"; + javaKeywordNavigationFilter = payload.javaKeywordNavigationFilter !== false; Object.assign(displayOptions, { ...payload.appearance, // Standalone updateOptions and newly created split editors both apply @@ -1272,6 +1433,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); @@ -1349,6 +1512,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 659df321b2..4a5f9fb3b1 100644 --- a/macos/EditorFrontend/index.html +++ b/macos/EditorFrontend/index.html @@ -2,7 +2,9 @@ -