From 727d1c802d31075845f90aa203fb7226870470cb Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 10:33:11 +0800 Subject: [PATCH 1/6] feat(macos): add editor find options and replace-in-file - Add FindInFileOptions/FindInFileMatcher with Match Case, Whole Words, and Regular Expression matching aligned with project search semantics - Extend FindBarView with an options menu, expandable replace row (Replace / Replace All), and invalid-regex error state - Replace-next routes through insertText for standard undo; replace-all commits via shouldChangeText + textStorage as a single undo step - Register replace-in-file (Cmd+R) in the command catalog, action registry, Navigate menu, Keymap settings, and zh-Hans localization Closes #327 --- .../2026-08-29-editor-find-replace-design.md | 123 +++++++++++++ ...026-08-30-editor-find-replace-tech-plan.md | 96 ++++++++++ .../zh-Hans.lproj/Localizable.strings | 3 + macos/Sources/Lithe/LitheApp.swift | 6 + .../AppModel/AppModel+FeatureState.swift | 2 +- .../Models/AppModel/AppModel+FindInFile.swift | 114 ++++++++++++ .../Lithe/Models/AppModel/AppModel.swift | 49 ----- .../AppModel/AppModelSupportTypes.swift | 6 + .../Models/Editor/EditorChromeModel.swift | 20 ++ .../Models/Editor/FindInFileMatcher.swift | 136 ++++++++++++++ .../Models/Keymap/LitheCommandCatalog.swift | 1 + macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../Lithe/Views/Editor/CodeEditorView.swift | 172 ++++++++++++++---- .../Lithe/Views/Editor/FindBarView.swift | 148 +++++++++++++-- .../LitheTests/EditorChromeModelTests.swift | 46 +++++ .../LitheTests/FindInFileMatcherTests.swift | 165 +++++++++++++++++ .../LitheTests/KeyboardShortcutTests.swift | 2 +- .../LitheTests/LitheCoreLogicTests.swift | 12 +- 18 files changed, 993 insertions(+), 109 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-29-editor-find-replace-design.md create mode 100644 docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift create mode 100644 macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift create mode 100644 macos/Tests/LitheTests/FindInFileMatcherTests.swift diff --git a/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md b/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md new file mode 100644 index 000000000..66873e607 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md @@ -0,0 +1,123 @@ +# 编辑器内查找替换设计 + +## 背景 + +GitHub Issue [#327](https://github.com/1lck/Lithe-IDEA/issues/327) 要求补全编辑器内的查找替换。当前 `FindBarView` 只有查找字段,匹配逻辑在 `CodeEditorView` 的 `updateFindMatches` 中写死大小写不敏感,没有大小写、全词、正则选项,也没有文件内替换。项目级搜索(`SearchSidebarView`、`ProjectReplaceView`)已有完整的选项与替换能力,编辑器内外的体验不一致;macOS 上 Cmd+R 目前未绑定。 + +本设计只覆盖 macOS,不改动 Rust Core,也不引入新的跨平台契约。 + +## 目标 + +- 文件内查找支持 Match Case、Whole Words、Regular Expression 三个选项,与项目搜索语义一致。 +- 查找栏可展开替换行,支持替换下一处和替换全部。 +- Cmd+R 呼出替换行,命令进入 `LitheCommandCatalog`,可在 Keymap 设置中自定义。 +- 替换走标准文本变更管线:整次替换全部是一个撤销步骤;诊断、行索引、Git 行标记、自动保存与手工编辑行为一致。 +- 非法正则给出可见提示,不影响编辑器其他功能。 + +## 非目标 + +- 不改动项目级搜索与项目级替换。 +- 不做多光标、查找历史持久化和查找结果面板。 +- 查找选项与替换文本只在当前工作区会话内保留,不写入设置。 +- 不改动 Diff 内搜索和共享契约。 + +## 功能范围 + +### 匹配选项 + +- 三个选项默认全部关闭,保持现有行为(大小写不敏感、音调不敏感)。 +- Whole Words 按标准词边界判断:字母、数字和下划线算词字符,串首串尾视为边界。 +- Regular Expression 使用 `NSRegularExpression` 语法;选项同时开启时模式外包一层 `\b(?:…)\b`。 +- 零宽度正则匹配(如 `a*`)跳过,避免无意义的高亮和替换。 + +### 替换 + +- Replace:替换当前匹配并自动跳到下一处匹配;跳过替换文本中新产生的匹配,避免与替换结果死循环。 +- Replace All:一次性替换全部匹配,整个操作只产生一个撤销步骤,撤销后恢复原文。 +- 替换完成后匹配列表、计数和高亮立即按新文本刷新。 +- 正则模式下替换模板按 `NSRegularExpression` 语义展开(`$1`、`${name}`);字面量模式下替换文本原样使用。 + +### 入口与快捷键 + +- 查找栏内提供选项菜单和替换行开关。 +- Cmd+F 打开查找(保持现状,隐藏替换行);Cmd+R 打开带替换行的查找栏;再次 Cmd+R 在查找/替换间切换。 +- Edit 菜单在 Find in File… 下方新增 Replace in File…。 +- `replace-in-file` 命令加入命令目录,默认绑定 Cmd+R,可在 Keymap 中修改。 + +### 键盘行为 + +- 替换输入框内 Return = 替换下一处,Shift+Return = 替换全部。 +- 查找框内 Return / Shift+Return 维持现状(下一个/上一个匹配)。 +- Esc 关闭查找栏,行为不变。 + +## 界面设计 + +FindBar 保持现有单行结构和宽度上限,纵向扩展: + +1. 第一行:选项菜单、查找框、n/m 计数、上/下一个、替换行开关、关闭。 +2. 选项菜单复用项目搜索的 `slider.horizontal.3` 图标与 Toggle 菜单样式;任一选项开启时图标着色。 +3. 替换行(可展开):替换图标、替换输入框、Replace 与 Replace All 按钮;无匹配时按钮禁用。 +4. 非法正则时查找图标显示错误色;修正查询后立即恢复。 + +## 应用分层与数据流 + +### Models + +- 新增 `Models/Editor/FindInFileOptions` 与 `FindInFileMatcher`:纯 Foundation 值类型,负责匹配枚举(字面量扫描、全词边界校验、正则枚举)与替换模板展开。不依赖 AppKit,保证可确定性单测。 +- `EditorChromeModel` 新增 `findOptions`、`isReplaceVisible`、`findReplaceText`;查找栏关闭再打开时选项保留。 + +### Application / AppModel + +- `showFindBar` 保持现有语义并隐藏替换行;新增 `showReplaceBar`。 +- 选项与替换文本 setter 直接更新 `EditorChromeModel`;查找栏现有通知通路扩展为同时携带查找选项。 +- 新增 `litheFindReplaceNext`、`litheFindReplaceAll` 通知,与现有 `litheFindNavigate` 走同一模式。 +- `canPerformShortcutCommand` 将 `replace-in-file` 与 `find-in-file` 同等对待(要求存在活动文档)。 + +### Views + +- `FindBarView` 只渲染状态并调用 `AppModel` 操作。 +- `CodeEditorView` 内的文本视图持有匹配列表: + - `updateFindMatches`、`applyFindEdit` 改用 `FindInFileMatcher`;保留现有的按行增量重算优化,重算窗口向两侧各扩一个字符,使全词边界能看到真实相邻字符。 + - 替换下一处通过 `insertText(_:replacementRange:)` 进入标准输入管线(撤销、委托回调、装饰刷新全部一致),随后选中下一处匹配。 + - 替换全部通过 `shouldChangeText` + `NSTextStorage` 批量替换 + `didChangeText` 一步完成,形成单个撤销项,之后整篇重算匹配。 + - `updateNSView` 的查找同步条件扩展到查找选项,切换选项立即重算计数与高亮。 + +## 冲突与错误处理 + +- 非法正则不抛错、不产生匹配,仅在查找栏显示错误状态。 +- 只读文档由现有 `isReadOnly` 守卫阻止变更,替换为无操作。 +- 替换行展开后焦点移动到替换输入框。 +- 替换全部的文本变更与一次全量编辑等价:诊断、Git 行标记、Local History、自动保存按现有管线自然触发。 + +## 测试策略 + +新增 `FindInFileMatcherTests`(Swift Testing,纯逻辑,无等待): + +- 字面量默认大小写不敏感(含音调不敏感回归)。 +- Match Case 精确匹配。 +- Whole Words 边界:下划线与数字算词字符、串首串尾边界、拒绝候选后继续向后扫描。 +- 正则捕获组模板展开;字面量模式替换文本原样使用。 +- 非法模式返回空匹配并报告无效。 +- 空查询返回空匹配;零宽度匹配被跳过。 + +扩展 `EditorChromeModelTests`:选项与替换行状态变更、`resetFindBar` 保留查找选项。 + +### 仓库验证 + +```bash +./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter FindInFileMatcher +./scripts/test-macos.sh +./scripts/verify-service-boundaries.sh +``` + +## 验收标准 + +1. Cmd+F 查找行为与现状一致;三个选项可在查找栏内切换并立即刷新 n/m 计数与高亮。 +2. Cmd+R 打开替换行,按钮与 Return / Shift+Return 均可替换;替换全部只产生一个撤销步骤,撤销后完全恢复原文。 +3. 替换当前处后自动跳到下一处匹配,不会立即命中替换文本本身。 +4. 全词、正则、大小写选项与项目搜索语义一致;正则替换模板支持捕获组。 +5. 非法正则显示错误状态,编辑器不崩溃、计数归零。 +6. 替换后诊断、Git 行标记与自动保存行为与手工编辑一致。 +7. Edit 菜单与 Keymap 中出现 Replace in File…,快捷键可自定义。 +8. 上述验证脚本全部通过。 diff --git a/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md b/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md new file mode 100644 index 000000000..e86c47d7f --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md @@ -0,0 +1,96 @@ +# 编辑器内查找替换技术方案 + +依据 `docs/superpowers/specs/2026-08-29-editor-find-replace-design.md` 与 Issue #327。 +范围仅限 macOS,不改动 Rust Core 与共享契约。 + +## 分层与文件变更清单 + +| 层 | 文件 | 变更 | +| --- | --- | --- | +| Models/Editor | `FindInFileMatcher.swift`(新增) | `FindInFileOptions` 与 `FindInFileMatcher` 纯 Foundation 值类型 | +| Models/Editor | `EditorChromeModel.swift` | 新增 `findOptions`、`isReplaceVisible`、`findReplaceText`;`resetFindBar` 保留选项与替换文本 | +| Models/AppModel | `AppModelSupportTypes.swift` | `FindNotificationKeys` 增加 `matchCase`/`wholeWords`/`regularExpression`/`replacement`;新增 `litheFindReplaceNext`、`litheFindReplaceAll` | +| Models/AppModel | `AppModel+FindInFile.swift`(新增) | 文件内查找/替换门面(访问器、`showFindBar`、`showReplaceBar`、`setFindOptions`、`replaceNextFindMatch`、`replaceAllFindMatches` 等),从 `AppModel.swift` 抽出以满足行数上限 | +| Models/AppModel | `AppModel.swift` | 既有文件内查找方法整体移至 `AppModel+FindInFile.swift`(净减行数) | +| Models/AppModel | `AppModel+FeatureState.swift` | `canPerformShortcutCommand` 将 `replace-in-file` 与 `find-in-file` 同等对待 | +| Models/Keymap | `LitheCommandCatalog.swift` | 新增 `replace-in-file`,默认绑定 Cmd+R | +| Models | `LitheAction.swift` | 注册 `replace-in-file` action | +| Views/Editor | `FindBarView.swift` | 选项菜单、替换行、非法正则错误色、焦点管理 | +| Views/Editor | `CodeEditorView.swift` | `CodeTextView` 选项化匹配、替换下一处/全部;`updateNSView` 与通知通路携带选项 | +| Views | `LitheApp.swift` | Navigate 菜单在 Find in File… 下方新增 Replace in File… | +| Resources | `zh-Hans.lproj/Localizable.strings` | 新增三条翻译(菜单标题、命令标题、命令副标题) | +| Tests | `FindInFileMatcherTests.swift`(新增)、`EditorChromeModelTests.swift`、`KeyboardShortcutTests.swift` | 见测试策略 | + +## 匹配语义(FindInFileMatcher) + +| 选项组合 | 实现 | +| --- | --- | +| 默认(全关) | `NSString.range(of:options:)` 扫描,`[.caseInsensitive, .diacriticInsensitive]`,保持现状 | +| Match Case | 同上,比较选项为空 | +| Whole Words(字面量) | 逐候选扫描 + 词边界校验;候选被拒后从下一字符继续,保证不漏掉重叠位置的合法匹配 | +| Regular Expression | `NSRegularExpression` 枚举,`matchCase` 为 false 时加 `.caseInsensitive` | +| Whole Words + Regex | 模式外包一层 `\b(?:…)\b`(非捕获,不影响分组编号) | + +- 词字符:字母(Unicode `isAlphabetic`)、数字(`numericType != nil`)和下划线;串首串尾视为边界。 +- 边界校验按 UTF-16 位置读取全文,并组合代理对后再分类,窗口扫描时也能看到真实相邻字符。 +- 正则编译失败:`isValid == false`,不产生匹配,不抛错。 +- 空查询返回空匹配;零宽度正则匹配(如 `a*`)跳过,不参与高亮与替换。 +- 替换模板:正则模式按 `NSRegularExpression` 语义展开(`$0`–`$9` 数字分组引用); + 字面量模式原样使用。注意:当前 SDK 的 `NSRegularExpression` 不会展开 `${name}` 命名分组模板, + 该类模板按平台行为原样返回。 +- `matchRanges(in:range:)` 支持子范围枚举,供按行增量重算复用;`enumerateMatches` 以全文为底、仅限制范围, + 使 `\b` 与边界校验始终基于真实上下文。 + +## 数据流与通知 + +- 查找栏关闭再打开:`findOptions`、`findReplaceText` 在会话内保留;`resetFindBar` 只重置可见性、查询与匹配计数,同时收起替换行。 +- `litheFindQueryChanged` 扩展为携带 `query` + 三个选项;`setFindBarQuery` 与 `setFindOptions` 都通过同一私有方法发送。 +- `CodeTextView` 的两个入口同步扩展: + - `updateNSView`:Coordinator 追踪 `lastFindOptions`,变化时走 `syncFindState(isVisible:query:options:)`; + - `handleFindQueryChanged`:从 `userInfo` 重建 `FindInFileOptions`。 +- `CodeTextView` 持有当前 `findMatcher`(查询 + 选项),`applyFindEdit` 与替换操作复用;查询与已存值不一致时按传入查询重建。 +- `litheFindReplaceNext` / `litheFindReplaceAll` 携带替换文本,`CodeTextView` 观察后执行替换,模式与 `litheFindNavigate` 一致。 + +## 编辑器替换管线 + +- 替换下一处:`insertText(_:replacementRange:)` 进入标准输入管线(撤销、`shouldChangeText` 委托、装饰刷新一致); + 完成后选中替换区之后的第一个匹配,跳过替换文本自身新产生的匹配;没有更靠后的匹配时从文档开头回绕, + 仍跳过与替换区重叠的匹配。 +- 替换全部:先基于当前匹配列表按模板展开重建全文,再 `shouldChangeText` + `NSTextStorage.replaceCharacters` + + `didChangeText` 一步提交,形成单个撤销步骤;随后整篇重算匹配并校正选区。 +- 匹配高亮、n/m 计数经由既有 `reportFindState` → `scheduleFindStateUpdate` 通路刷新。 +- 只读文档:文本视图 `isEditable == false`,两个替换入口先检查 `isEditable`,`shouldChangeText` 返回 false,替换为无操作。 +- 诊断、Git 行标记、Local History、自动保存由 `textDidChange` 既有管线自然触发,与手工编辑等价。 + +## 按行增量重算窗口 + +`applyFindEdit` 保留按行增量优化,重算窗口在编辑所在行基础上向两侧各扩一个字符(夹取到文档边界), +并移除所有与窗口相交的旧匹配后重新枚举。扩一个字符的原因:行首/行尾匹配的全词边界落在相邻行, +编辑相邻行的首尾字符会改变其合法性,只有窗口覆盖到该字符才能移除并重算。 + +## 命令与菜单 + +- `replace-in-file` 加入 `LitheCommandCatalog`(Navigation 组,默认 Cmd+R),可在 Keymap 设置中自定义; + Cmd+R 与现有 `run`(Ctrl+R)、`replace-in-project`(Shift+Cmd+R)无冲突。 +- `LitheActionRegistry`、`performShortcutCommand`/`canPerformShortcutCommand`、Navigate 菜单同步注册; + `KeyboardShortcutTests` 中命令总数断言 31 → 32。 +- `AppLocalizationTests` 要求命令标题/副标题有 zh-Hans 翻译,补齐三条词条。 + +## 测试策略 + +新增 `FindInFileMatcherTests`(Swift Testing,纯同步逻辑,无等待): +字面量默认大小写/音调不敏感回归、Match Case 精确匹配、Whole Words 边界(下划线与数字、串首串尾、 +拒绝候选后继续扫描)、正则捕获组模板展开、字面量替换原样、非法模式 `isValid == false` 且空匹配、 +空查询、零宽度跳过、子范围枚举与 `\b` 包裹组合。 + +扩展 `EditorChromeModelTests`:`findOptions`/`isReplaceVisible`/`findReplaceText` 仅在变化时发布; +`resetFindBar` 保留选项与替换文本、收起替换行。 + +## 验证 + +```bash +./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter FindInFileMatcher +./scripts/test-macos.sh +./scripts/verify-service-boundaries.sh +``` diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 96665fd4d..282793945 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -249,6 +249,7 @@ "Navigate" = "导航"; "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; +"Replace in File…" = "在文件中替换…"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -901,6 +902,8 @@ "Search text across the workspace" = "搜索整个工作区的文本"; "Find in File" = "在文件中查找"; "Search within the active editor" = "在当前编辑器中搜索"; +"Replace in File" = "在文件中替换"; +"Replace within the active editor" = "在当前编辑器中替换"; "Navigate to a call site of the selected Java symbol" = "导航到所选 Java 符号的调用位置"; "Find references to the selected Java symbol" = "查找所选 Java 符号的引用"; "Open history for the active file" = "打开当前文件的历史记录"; diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index fbf7b1914..a420e07f0 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -335,6 +335,12 @@ struct LitheApp: App { .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-in-file")) .disabled(model.activeDocument == nil) + Button("Replace in File…") { + model.showReplaceBar() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "replace-in-file")) + .disabled(model.activeDocument == nil) + Button("Find Next") { model.navigateFind(offset: 1) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2b0a9c53b..6cb844336 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -330,7 +330,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "local-history", "reveal-in-finder": + case "save", "find-in-file", "replace-in-file", "local-history", "reveal-in-finder": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift new file mode 100644 index 000000000..a18a78cb4 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift @@ -0,0 +1,114 @@ +import Foundation + +/// AppModel 的文件内查找/替换门面:读写 `EditorChromeModel` 的查找状态, +/// 并通过既有通知通路驱动当前编辑器的文本视图。 +extension AppModel { + var isFindBarVisible: Bool { + get { editorChrome.isFindBarVisible } + set { editorChrome.setFindBarVisible(newValue) } + } + var findBarQuery: String { + get { editorChrome.findBarQuery } + set { editorChrome.setFindBarQuery(newValue) } + } + var findOptions: FindInFileOptions { + get { editorChrome.findOptions } + set { editorChrome.setFindOptions(newValue) } + } + var isReplaceVisible: Bool { + get { editorChrome.isReplaceVisible } + set { editorChrome.setReplaceVisible(newValue) } + } + var findReplaceText: String { + get { editorChrome.findReplaceText } + set { editorChrome.setFindReplaceText(newValue) } + } + var findMatchCount: Int { editorChrome.findMatchCount } + var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } + + func showFindBar() { + guard activeDocument != nil else { return } + editorChrome.setFindBarVisible(true) + editorChrome.setReplaceVisible(false) + } + + /// Cmd+R:查找栏未显示时带替换行打开,否则在查找/替换之间切换。 + func showReplaceBar() { + guard activeDocument != nil else { return } + if isFindBarVisible { + editorChrome.setReplaceVisible(!editorChrome.isReplaceVisible) + } else { + editorChrome.setFindBarVisible(true) + editorChrome.setReplaceVisible(true) + } + } + + func hideFindBar() { + editorChrome.resetFindBar() + NotificationCenter.default.post(name: .litheFindDismiss, object: nil) + } + + func toggleFindBar() { + if isFindBarVisible { + hideFindBar() + } else { + showFindBar() + } + } + + func setFindBarQuery(_ query: String) { + editorChrome.setFindBarQuery(query) + postFindQueryChangedNotification() + } + + func setFindOptions(_ options: FindInFileOptions) { + editorChrome.setFindOptions(options) + postFindQueryChangedNotification() + } + + func setFindReplaceText(_ text: String) { + editorChrome.setFindReplaceText(text) + } + + private func postFindQueryChangedNotification() { + let options = editorChrome.findOptions + NotificationCenter.default.post( + name: .litheFindQueryChanged, + object: nil, + userInfo: [ + FindNotificationKeys.query: editorChrome.findBarQuery, + FindNotificationKeys.matchCase: options.matchCase, + FindNotificationKeys.wholeWords: options.wholeWords, + FindNotificationKeys.regularExpression: options.regularExpression + ] + ) + } + + func navigateFind(offset: Int) { + NotificationCenter.default.post( + name: .litheFindNavigate, + object: nil, + userInfo: [FindNotificationKeys.direction: offset] + ) + } + + func replaceNextFindMatch() { + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [FindNotificationKeys.replacement: editorChrome.findReplaceText] + ) + } + + func replaceAllFindMatches() { + NotificationCenter.default.post( + name: .litheFindReplaceAll, + object: nil, + userInfo: [FindNotificationKeys.replacement: editorChrome.findReplaceText] + ) + } + + func updateFindState(currentIndex: Int, count: Int) { + editorChrome.updateFindState(currentIndex: currentIndex, count: count) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 21b5f6c86..092848fb5 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -87,16 +87,6 @@ final class AppModel: ObservableObject, Identifiable { } /// 递增令牌:搜索侧栏观察它来把焦点移回输入框。 @Published var searchSidebarFocusRequest = 0 - var isFindBarVisible: Bool { - get { editorChrome.isFindBarVisible } - set { editorChrome.setFindBarVisible(newValue) } - } - var findBarQuery: String { - get { editorChrome.findBarQuery } - set { editorChrome.setFindBarQuery(newValue) } - } - var findMatchCount: Int { editorChrome.findMatchCount } - var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } var projectItemEditRequest: ProjectItemEditRequest? { get { workspaceFeature.projectItemEditRequest } set { workspaceFeature.projectItemEditRequest = newValue } @@ -1407,45 +1397,6 @@ final class AppModel: ObservableObject, Identifiable { } } - func showFindBar() { - guard activeDocument != nil else { return } - editorChrome.setFindBarVisible(true) - } - - func hideFindBar() { - editorChrome.resetFindBar() - NotificationCenter.default.post(name: .litheFindDismiss, object: nil) - } - - func toggleFindBar() { - if isFindBarVisible { - hideFindBar() - } else { - showFindBar() - } - } - - func setFindBarQuery(_ query: String) { - editorChrome.setFindBarQuery(query) - NotificationCenter.default.post( - name: .litheFindQueryChanged, - object: nil, - userInfo: [FindNotificationKeys.query: query] - ) - } - - func navigateFind(offset: Int) { - NotificationCenter.default.post( - name: .litheFindNavigate, - object: nil, - userInfo: [FindNotificationKeys.direction: offset] - ) - } - - func updateFindState(currentIndex: Int, count: Int) { - editorChrome.updateFindState(currentIndex: currentIndex, count: count) - } - func commitStagedChanges() async { guard let gitFeature = await activateGitModule() else { return } if await gitFeature.commitStagedChanges(message: commitMessage, amend: amendCommit) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index f14307d83..f8325bd03 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -83,12 +83,18 @@ typealias ProjectItemDeletionRequest = LitheCoreContracts.ProjectItemDeletionReq enum FindNotificationKeys { static let query = "query" static let direction = "direction" + static let matchCase = "matchCase" + static let wholeWords = "wholeWords" + static let regularExpression = "regularExpression" + static let replacement = "replacement" } extension Notification.Name { static let litheFindQueryChanged = Notification.Name("litheFindQueryChanged") static let litheFindNavigate = Notification.Name("litheFindNavigate") static let litheFindDismiss = Notification.Name("litheFindDismiss") + static let litheFindReplaceNext = Notification.Name("litheFindReplaceNext") + static let litheFindReplaceAll = Notification.Name("litheFindReplaceAll") } struct ProjectTreeRevealRequest: Equatable { diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 9e5e76091..9931bc4b5 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -10,6 +10,9 @@ final class EditorChromeModel: ObservableObject { @Published private(set) var selectedText = "" @Published private(set) var isFindBarVisible = false @Published private(set) var findBarQuery = "" + @Published private(set) var findOptions = FindInFileOptions() + @Published private(set) var isReplaceVisible = false + @Published private(set) var findReplaceText = "" private(set) var findMatchCount = 0 private(set) var currentFindMatchIndex = 0 @@ -33,6 +36,21 @@ final class EditorChromeModel: ObservableObject { findBarQuery = query } + func setFindOptions(_ options: FindInFileOptions) { + guard findOptions != options else { return } + findOptions = options + } + + func setReplaceVisible(_ isVisible: Bool) { + guard isReplaceVisible != isVisible else { return } + isReplaceVisible = isVisible + } + + func setFindReplaceText(_ text: String) { + guard findReplaceText != text else { return } + findReplaceText = text + } + func updateFindState(currentIndex: Int, count: Int) { guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } objectWillChange.send() @@ -40,9 +58,11 @@ final class EditorChromeModel: ObservableObject { findMatchCount = count } + /// 查找选项与替换文本在当前工作区会话内保留,关闭查找栏不重置。 func resetFindBar() { setFindBarVisible(false) setFindBarQuery("") + setReplaceVisible(false) updateFindState(currentIndex: 0, count: 0) } diff --git a/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift new file mode 100644 index 000000000..e8fd9f781 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift @@ -0,0 +1,136 @@ +import Foundation + +/// 文件内查找的匹配选项:Match Case、Whole Words、Regular Expression。 +/// 三个选项默认全部关闭,保持既有的大小写与音调不敏感行为。 +struct FindInFileOptions: Equatable, Sendable { + var matchCase = false + var wholeWords = false + var regularExpression = false + + static let `default` = FindInFileOptions() +} + +/// 按选项枚举文件内匹配并展开替换模板的纯 Foundation 值类型。 +/// 不依赖 AppKit,保证匹配结果可确定、可单测。 +struct FindInFileMatcher { + let query: String + let options: FindInFileOptions + + /// 正则模式编译失败时为 false;其余模式始终有效。 + var isValid: Bool { + !options.regularExpression || expression != nil + } + + /// 仅正则模式使用;字面量扫描走 NSString 比较。 + private let expression: NSRegularExpression? + + init(query: String, options: FindInFileOptions) { + self.query = query + self.options = options + if options.regularExpression { + // Whole Words 与正则同时开启时按设计外包一层 \b(?:…)\b; + // 使用非捕获分组,保持模板中 $1 等分组编号不变。 + let pattern = options.wholeWords ? "\\b(?:\(query))\\b" : query + self.expression = try? NSRegularExpression( + pattern: pattern, + options: options.matchCase ? [] : [.caseInsensitive] + ) + } else { + self.expression = nil + } + } + + /// 枚举全文匹配:升序、互不重叠、跳过零宽度匹配。 + func matchRanges(in source: NSString) -> [NSRange] { + matchRanges(in: source, range: NSRange(location: 0, length: source.length)) + } + + /// 枚举指定范围内的匹配。枚举以全文为底、仅限制范围, + /// 使 \b 与词边界校验始终基于真实上下文而不是窗口局部文本。 + func matchRanges(in source: NSString, range: NSRange) -> [NSRange] { + guard !query.isEmpty, range.location >= 0, NSMaxRange(range) <= source.length else { return [] } + if let expression { + return regexMatchRanges(expression: expression, in: source, range: range) + } + return literalMatchRanges(in: source, range: range) + } + + /// 展开替换模板:正则模式按 NSRegularExpression 语义($1、${name}), + /// 字面量模式原样返回。匹配列表与当前文本不一致时按字面量处理。 + func replacement(for source: NSString, matchRange: NSRange, template: String) -> String { + guard let expression else { return template } + let searchRange = NSRange( + location: matchRange.location, + length: max(0, source.length - matchRange.location) + ) + guard let match = expression.firstMatch(in: source as String, range: searchRange), + match.range == matchRange else { + return template + } + // offset 仅锚定模板中的 \G;$n / ${name} 展开不受影响 + return expression.replacementString(for: match, in: source as String, offset: 0, template: template) + } + + private func regexMatchRanges( + expression: NSRegularExpression, + in source: NSString, + range: NSRange + ) -> [NSRange] { + var ranges: [NSRange] = [] + expression.enumerateMatches(in: source as String, options: [], range: range) { match, _, _ in + guard let match else { return } + // 零宽度匹配(如 a*)不参与高亮和替换 + guard match.range.length > 0 else { return } + ranges.append(match.range) + } + return ranges + } + + private func literalMatchRanges(in source: NSString, range: NSRange) -> [NSRange] { + let compareOptions: String.CompareOptions = options.matchCase + ? [] + : [.caseInsensitive, .diacriticInsensitive] + var ranges: [NSRange] = [] + var cursor = range + while cursor.length > 0 { + let found = source.range(of: query, options: compareOptions, range: cursor) + guard found.location != NSNotFound else { break } + if !options.wholeWords || isWholeWordMatch(found, in: source) { + ranges.append(found) + cursor.location = NSMaxRange(found) + } else { + // 拒绝候选后从下一字符继续,保证重叠位置的合法匹配不被漏掉 + cursor.location = found.location + 1 + } + cursor.length = max(0, NSMaxRange(range) - cursor.location) + } + return ranges + } + + /// 全词判定:匹配两侧都不是词字符(串首串尾视为边界)。 + private func isWholeWordMatch(_ range: NSRange, in source: NSString) -> Bool { + !Self.isWordCharacter(at: range.location - 1, in: source) + && !Self.isWordCharacter(at: NSMaxRange(range), in: source) + } + + /// 词字符 = 字母、数字和下划线;越界(串首串尾)返回 false。 + /// 按 UTF-16 位置判断,必要时组合代理对还原完整字符后再分类。 + private static func isWordCharacter(at index: Int, in source: NSString) -> Bool { + guard index >= 0, index < source.length else { return false } + var value = UInt32(source.character(at: index)) + if (0xDC00...0xDFFF).contains(value), index > 0 { + // 低代理半区:与前面的高代理组合成完整字符 + let previous = UInt32(source.character(at: index - 1)) + guard (0xD800...0xDBFF).contains(previous) else { return false } + value = 0x10000 + (previous - 0xD800) * 0x400 + (value - 0xDC00) + } else if (0xD800...0xDBFF).contains(value), index + 1 < source.length { + let next = UInt32(source.character(at: index + 1)) + guard (0xDC00...0xDFFF).contains(next) else { return false } + value = 0x10000 + (value - 0xD800) * 0x400 + (next - 0xDC00) + } + guard let scalar = Unicode.Scalar(value) else { return false } + return scalar == "_" + || scalar.properties.isAlphabetic + || scalar.properties.numericType != nil + } +} diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 66ab4fc2c..521e44df7 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -37,6 +37,7 @@ enum LitheCommandCatalog { command("find-in-file", "Find in File", "Search within the active editor", .navigation, "f", [.command]), command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), + command("replace-in-file", "Replace in File", "Replace within the active editor", .navigation, "r", [.command]), command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index f9b19f6b8..4cf06a432 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -80,6 +80,7 @@ enum LitheActionRegistry { action("search-in-project", model: model) { model.openProjectSearch() }, action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, + action("replace-in-file", model: model) { model.showReplaceBar() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 197534cca..fa45158a0 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -541,11 +541,14 @@ struct CodeEditorView: NSViewRepresentable { if let codeTextView = textView as? CodeTextView { let findVisible = chrome.isFindBarVisible let findQuery = chrome.findBarQuery + let findOptions = chrome.findOptions if context.coordinator.lastFindVisible != findVisible - || context.coordinator.lastFindQuery != findQuery { + || context.coordinator.lastFindQuery != findQuery + || context.coordinator.lastFindOptions != findOptions { context.coordinator.lastFindVisible = findVisible context.coordinator.lastFindQuery = findQuery - codeTextView.syncFindState(isVisible: findVisible, query: findQuery) + context.coordinator.lastFindOptions = findOptions + codeTextView.syncFindState(isVisible: findVisible, query: findQuery, options: findOptions) } } context.coordinator.applySynchronizedMarkdownScrollIfNeeded(to: container.scrollView) @@ -573,6 +576,7 @@ struct CodeEditorView: NSViewRepresentable { var implementationMarkers: [JavaImplementationMarker] = [] var lastFindVisible = false var lastFindQuery = "" + var lastFindOptions = FindInFileOptions() private var pendingHighlightRange: NSRange? private var pendingReplacedRange: NSRange? private var pendingReplacement: String? @@ -1106,7 +1110,7 @@ struct CodeEditorView: NSViewRepresentable { try? await Task.sleep(for: .milliseconds(80)) guard !Task.isCancelled, let self, let textView = self.textView as? CodeTextView else { return } if let model = self.model, model.isFindBarVisible, !model.findBarQuery.isEmpty { - textView.updateFindMatches(query: model.findBarQuery) + textView.updateFindMatches(query: model.findBarQuery, options: model.findOptions) } else { textView.updateEditorDecorations() } @@ -1498,6 +1502,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var findMatchRanges: [NSRange] = [] private var currentFindMatchIndex = 0 private var lastReportedFindState: (index: Int, count: Int)? + private var findMatcher = FindInFileMatcher(query: "", options: .default) private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -1699,6 +1704,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { clearFindHighlights() return } + let matcher = query == findMatcher.query + ? findMatcher + : FindInFileMatcher(query: query, options: findMatcher.options) + findMatcher = matcher let source = string as NSString let delta = insertedLength - replacedRange.length let replacedEnd = NSMaxRange(replacedRange) @@ -1709,34 +1718,26 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } return nil } + // 重算窗口 = 编辑所在行向两侧各扩一个字符:行边界处全词匹配的 + // 边界字符可能落在相邻行,只有窗口覆盖该字符才能正确移除并重算。 let safeLocation = min(replacedRange.location, max(0, source.length - 1)) let lineRange = source.length == 0 ? NSRange(location: 0, length: 0) : source.lineRange(for: NSRange(location: safeLocation, length: 0)) - let searchEnd = min(source.length, max(NSMaxRange(lineRange), replacedRange.location + insertedLength)) + let windowLocation = max(0, lineRange.location - 1) + let windowEnd = min(source.length, NSMaxRange(lineRange) + 1) let searchRange = NSRange( - location: lineRange.location, - length: max(0, searchEnd - lineRange.location) + location: windowLocation, + length: max(0, windowEnd - windowLocation) ) findMatchRanges.removeAll { range in NSIntersectionRange(range, searchRange).length > 0 || (range.location >= searchRange.location && range.location < NSMaxRange(searchRange)) } - if searchRange.length > 0, !query.isEmpty { - var cursor = searchRange - while cursor.length > 0 { - let found = source.range( - of: query, - options: [.caseInsensitive, .diacriticInsensitive], - range: cursor - ) - if found.location == NSNotFound { break } - findMatchRanges.append(found) - let nextLocation = NSMaxRange(found) - cursor = NSRange(location: nextLocation, length: NSMaxRange(searchRange) - nextLocation) - } - findMatchRanges.sort { $0.location < $1.location } + for found in matcher.matchRanges(in: source, range: searchRange) { + findMatchRanges.append(found) } + findMatchRanges.sort { $0.location < $1.location } currentFindMatchIndex = min(currentFindMatchIndex, max(0, findMatchRanges.count - 1)) applyFindHighlights() reportFindState( @@ -1881,25 +1882,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { // MARK: - Find in file - /// 重新计算匹配范围并刷新高亮,用于 Find Bar 查询变化。 + /// 重新计算匹配范围并刷新高亮,用于 Find Bar 查询或选项变化。 /// 通过 updateEditorDecorations 统一重画,避免旧查询高亮残留。 - func updateFindMatches(query: String) { + func updateFindMatches(query: String, options: FindInFileOptions) { + let matcher = FindInFileMatcher(query: query, options: options) + findMatcher = matcher let source = string as NSString - var newRanges: [NSRange] = [] - if !query.isEmpty { - var searchRange = NSRange(location: 0, length: source.length) - while searchRange.length > 0 { - let found = source.range( - of: query, - options: [.caseInsensitive, .diacriticInsensitive], - range: searchRange - ) - if found.location == NSNotFound { break } - newRanges.append(found) - let nextLocation = NSMaxRange(found) - searchRange = NSRange(location: nextLocation, length: source.length - nextLocation) - } - } + let newRanges = matcher.matchRanges(in: source) let needsRefresh = !findMatchRanges.isEmpty || !newRanges.isEmpty let previousIndex = currentFindMatchIndex let previousRanges = findMatchRanges @@ -1930,6 +1919,80 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { reportFindState(index: currentFindMatchIndex, count: total) } + /// 替换当前匹配并自动跳到下一处:通过 insertText 进入标准输入管线, + /// 撤销、委托回调与装饰刷新同手工编辑一致。 + func replaceNextFindMatch(replacement: String) { + guard isEditable, + !findMatchRanges.isEmpty, + currentFindMatchIndex < findMatchRanges.count else { return } + let matchRange = findMatchRanges[currentFindMatchIndex] + let expanded = findMatcher.replacement( + for: string as NSString, + matchRange: matchRange, + template: replacement + ) + let replacedRange = NSRange(location: matchRange.location, length: (expanded as NSString).length) + insertText(expanded, replacementRange: matchRange) + // 跳过替换文本自身新产生的匹配,避免与替换结果死循环 + selectFindMatch(after: replacedRange) + } + + /// 一次性替换全部匹配:shouldChangeText + NSTextStorage 批量替换 + + /// didChangeText 一步完成,形成单个撤销步骤;之后整篇重算匹配。 + func replaceAllFindMatches(replacement: String) { + guard isEditable, !findMatchRanges.isEmpty else { return } + let source = string as NSString + let fullRange = NSRange(location: 0, length: source.length) + let rebuilt = rebuiltTextByReplacingMatches(with: replacement, in: source) + guard shouldChangeText(in: fullRange, replacementString: rebuilt) else { return } + textStorage?.replaceCharacters(in: fullRange, with: rebuilt) + didChangeText() + let length = (string as NSString).length + setSelectedRange(NSRange(location: min(selectedRange().location, length), length: 0)) + updateFindMatches(query: findMatcher.query, options: findMatcher.options) + } + + private func rebuiltTextByReplacingMatches(with template: String, in source: NSString) -> String { + let rebuilt = NSMutableString() + var cursor = 0 + for range in findMatchRanges { + guard range.location >= cursor else { continue } + rebuilt.append(source.substring(with: NSRange(location: cursor, length: range.location - cursor))) + rebuilt.append(findMatcher.replacement(for: source, matchRange: range, template: template)) + cursor = NSMaxRange(range) + } + if cursor < source.length { + rebuilt.append(source.substring(with: NSRange(location: cursor, length: source.length - cursor))) + } + return rebuilt as String + } + + /// 选中替换区之后的第一个匹配;没有更靠后的匹配时从文档开头回绕, + /// 两种情况都跳过与替换区重叠的匹配。 + private func selectFindMatch(after replacedRange: NSRange) { + if let next = findMatchRanges.firstIndex(where: { $0.location >= NSMaxRange(replacedRange) }) { + selectFindMatch(at: next) + return + } + if let wrapped = findMatchRanges.firstIndex(where: { !Self.overlapsFindRange($0, replacedRange) }) { + selectFindMatch(at: wrapped) + } + } + + private static func overlapsFindRange(_ range: NSRange, _ other: NSRange) -> Bool { + NSIntersectionRange(range, other).length > 0 + || (range.location >= other.location && range.location < NSMaxRange(other)) + } + + private func selectFindMatch(at index: Int) { + currentFindMatchIndex = index + applyFindHighlights() + let range = findMatchRanges[index] + scrollRangeToVisible(range) + setSelectedRange(range) + reportFindState(index: index, count: findMatchRanges.count) + } + /// Publishes only meaningful find-state transitions so SwiftUI updates do /// not create a feedback loop through `updateNSView`. private func reportFindState(index: Int, count: Int) { @@ -1950,9 +2013,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } /// 文档或查询变化时同步 Find Bar 状态;Find Bar 关闭时仅清理已有高亮。 - func syncFindState(isVisible: Bool, query: String) { + func syncFindState(isVisible: Bool, query: String, options: FindInFileOptions) { if isVisible { - updateFindMatches(query: query) + updateFindMatches(query: query, options: options) } else if !findMatchRanges.isEmpty { clearFindHighlights() } @@ -3015,6 +3078,18 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { name: .litheFindDismiss, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFindReplaceNext(_:)), + name: .litheFindReplaceNext, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFindReplaceAll(_:)), + name: .litheFindReplaceAll, + object: nil + ) } required init?(coder: NSCoder) { @@ -3030,7 +3105,24 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { @objc private func handleFindQueryChanged(_ notification: Notification) { let query = notification.userInfo?[FindNotificationKeys.query] as? String ?? "" - updateFindMatches(query: query) + let options = FindInFileOptions( + matchCase: notification.userInfo?[FindNotificationKeys.matchCase] as? Bool ?? false, + wholeWords: notification.userInfo?[FindNotificationKeys.wholeWords] as? Bool ?? false, + regularExpression: notification.userInfo?[FindNotificationKeys.regularExpression] as? Bool ?? false + ) + updateFindMatches(query: query, options: options) + } + + @objc private func handleFindReplaceNext(_ notification: Notification) { + replaceNextFindMatch( + replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" + ) + } + + @objc private func handleFindReplaceAll(_ notification: Notification) { + replaceAllFindMatches( + replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" + ) } @objc private func handleFindNavigate(_ notification: Notification) { diff --git a/macos/Sources/Lithe/Views/Editor/FindBarView.swift b/macos/Sources/Lithe/Views/Editor/FindBarView.swift index 8f03a6ab6..05d5aa827 100644 --- a/macos/Sources/Lithe/Views/Editor/FindBarView.swift +++ b/macos/Sources/Lithe/Views/Editor/FindBarView.swift @@ -1,10 +1,13 @@ import SwiftUI -/// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭。 +/// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭; +/// 可展开替换行(Replace / Replace All)并携带 Match Case、Whole Words、 +/// Regular Expression 选项。 struct FindBarView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var chrome: EditorChromeModel - @FocusState private var focused: Bool + @FocusState private var findFocused: Bool + @FocusState private var replaceFocused: Bool private var queryBinding: Binding { Binding( @@ -13,17 +16,53 @@ struct FindBarView: View { ) } + private var replaceBinding: Binding { + Binding( + get: { chrome.findReplaceText }, + set: { model.setFindReplaceText($0) } + ) + } + var body: some View { + VStack(spacing: 6) { + findRow + .frame(height: 34) + if chrome.isReplaceVisible { + replaceRow + .frame(height: 34) + } + } + .padding(.horizontal, 10) + .frame(maxWidth: 520) + .lithePopupChrome(cornerRadius: 7) + .onAppear { findFocused = true } + .onChange(of: chrome.isReplaceVisible) { isVisible in + // 替换行展开后焦点移动到替换输入框,收起时还给查找框 + if isVisible { + replaceFocused = true + } else { + findFocused = true + } + } + .onExitCommand { + model.hideFindBar() + } + } + + private var findRow: some View { HStack(spacing: 7) { + optionsMenu + LitheSystemIcon(systemImage: "magnifyingglass") .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(queryIsInvalidRegex ? LitheTheme.error : LitheTheme.secondaryText) + .help(queryIsInvalidRegex ? "Invalid regular expression" : "") TextField("Find in file", text: queryBinding) .textFieldStyle(.plain) .font(.system(size: 12.5)) - .focused($focused) - .macReturnKeyHandler(isEnabled: focused) { isShiftPressed in + .focused($findFocused) + .macReturnKeyHandler(isEnabled: findFocused) { isShiftPressed in if isShiftPressed { model.navigateFind(offset: -1) } else { @@ -57,6 +96,15 @@ struct FindBarView: View { .disabled(chrome.findMatchCount == 0) .help("Next match (Return)") + Button { + model.isReplaceVisible.toggle() + } label: { + Image(systemName: chrome.isReplaceVisible ? "chevron.down" : "chevron.right") + } + .litheIconButton() + .foregroundStyle(chrome.isReplaceVisible ? LitheTheme.accent : LitheTheme.secondaryText) + .help(chrome.isReplaceVisible ? "Hide replace" : "Show replace") + Button { model.hideFindBar() } label: { @@ -66,14 +114,90 @@ struct FindBarView: View { .foregroundStyle(LitheTheme.secondaryText) .help("Close (Esc)") } - .padding(.horizontal, 10) - .frame(height: 34) - .frame(maxWidth: 520) - .lithePopupChrome(cornerRadius: 7) - .onAppear { focused = true } - .onExitCommand { - model.hideFindBar() + } + + private var replaceRow: some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: "arrow.left.arrow.right") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.leading, 22) + + TextField("Replace with", text: replaceBinding) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .focused($replaceFocused) + .macReturnKeyHandler(isEnabled: replaceFocused) { isShiftPressed in + if isShiftPressed { + model.replaceAllFindMatches() + } else { + model.replaceNextFindMatch() + } + } + + Button { + model.replaceNextFindMatch() + } label: { + Text("Replace") + .font(.system(size: 11.5, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(chrome.findMatchCount == 0 ? LitheTheme.secondaryText : LitheTheme.accent) + .disabled(chrome.findMatchCount == 0) + .help("Replace current match (Return)") + + Button { + model.replaceAllFindMatches() + } label: { + Text("Replace All") + .font(.system(size: 11.5, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(chrome.findMatchCount == 0 ? LitheTheme.secondaryText : LitheTheme.accent) + .disabled(chrome.findMatchCount == 0) + .help("Replace all matches (Shift+Return)") + } + } + + private var optionsMenu: some View { + Menu { + Toggle("Match Case", isOn: optionBinding(\.matchCase)) + Toggle("Whole Words", isOn: optionBinding(\.wholeWords)) + Toggle("Regular Expression", isOn: optionBinding(\.regularExpression)) + } label: { + LitheSystemIcon( + systemImage: hasActiveOptions ? "slider.horizontal.3.circle.fill" : "slider.horizontal.3" + ) + .font(.system(size: 11.5)) + .foregroundStyle(hasActiveOptions ? LitheTheme.accent : LitheTheme.secondaryText) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .lithePointer() + .help("Find options") + } + + private var hasActiveOptions: Bool { + chrome.findOptions != .default + } + + private var queryIsInvalidRegex: Bool { + !chrome.findBarQuery.isEmpty + && chrome.findOptions.regularExpression + && !FindInFileMatcher(query: chrome.findBarQuery, options: chrome.findOptions).isValid + } + + private func optionBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { chrome.findOptions[keyPath: keyPath] }, + set: { newValue in + var options = chrome.findOptions + options[keyPath: keyPath] = newValue + model.setFindOptions(options) + } + ) } private var matchLabel: String { diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 797e17d9b..0bd6773ab 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -72,4 +72,50 @@ struct EditorChromeModelTests { chrome.updateFindState(currentIndex: 0, count: 2) #expect(publishCount == 2) } + + @Test + func findOptionAndReplaceStateChangesPublishOnceEach() { + let chrome = EditorChromeModel() + let options = FindInFileOptions(matchCase: true, wholeWords: false, regularExpression: false) + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.setFindOptions(options) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + #expect(publishCount == 3) + + chrome.setFindOptions(options) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + #expect(publishCount == 3) + + #expect(chrome.findOptions == options) + #expect(chrome.isReplaceVisible) + #expect(chrome.findReplaceText == "bar") + } + + @Test + func resetFindBarKeepsFindOptionsAndReplaceText() { + // 查找选项与替换文本在当前会话内保留;可见性与匹配计数被重置 + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setFindOptions(FindInFileOptions(matchCase: false, wholeWords: true, regularExpression: true)) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 2) + + chrome.resetFindBar() + + #expect(!chrome.isFindBarVisible) + #expect(chrome.findBarQuery.isEmpty) + #expect(!chrome.isReplaceVisible) + #expect( + chrome.findOptions == FindInFileOptions(matchCase: false, wholeWords: true, regularExpression: true) + ) + #expect(chrome.findReplaceText == "bar") + } } diff --git a/macos/Tests/LitheTests/FindInFileMatcherTests.swift b/macos/Tests/LitheTests/FindInFileMatcherTests.swift new file mode 100644 index 000000000..e6990f1cf --- /dev/null +++ b/macos/Tests/LitheTests/FindInFileMatcherTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import Lithe + +struct FindInFileMatcherTests { + private let defaultOptions = FindInFileOptions() + + @Test + func literalSearchIsCaseAndDiacriticInsensitiveByDefault() { + // 回归保护:默认行为与既有查找一致(大小写、音调都不敏感) + let source = "Café cafe CAFE" as NSString + let matcher = FindInFileMatcher(query: "cafe", options: defaultOptions) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 4), NSRange(location: 5, length: 4), NSRange(location: 10, length: 4)]) + } + + @Test + func matchCaseRequiresExactCase() { + let source = "Café cafe CAFE" as NSString + let matcher = FindInFileMatcher( + query: "cafe", + options: FindInFileOptions(matchCase: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 5, length: 4)]) + } + + @Test + func wholeWordsRejectsCandidatesAndKeepsScanning() { + // 下划线与数字算词字符、串首串尾视为边界;被拒候选之后继续向后扫描 + let source = "cat catalog _cat cat1 cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 3), NSRange(location: 22, length: 3)]) + } + + @Test + func wholeWordsTreatsUnicodeLettersAsWordCharacters() { + let source = "écat cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 5, length: 3)]) + } + + @Test + func regularExpressionEnumeratesMatches() { + let source = "alice@example.com bob@test.org" as NSString + let matcher = FindInFileMatcher( + query: "(\\w+)@(\\w+)", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 13), NSRange(location: 18, length: 8)]) + } + + @Test + func regularExpressionReplacementExpandsCaptureGroups() { + let source = "alice@example.com bob@test.org" as NSString + let matcher = FindInFileMatcher( + query: "(\\w+)@(\\w+)", + options: FindInFileOptions(regularExpression: true) + ) + + // NSRegularExpression 的模板只展开 $n 数字引用;${name} 原样返回 + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 13), template: "$2.$1") + == "example.alice" + ) + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 18, length: 8), template: "$2.$1") + == "test.bob" + ) + } + + @Test + func literalReplacementUsesTemplateVerbatim() { + let source = "foo bar" as NSString + let matcher = FindInFileMatcher(query: "foo", options: defaultOptions) + + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 3), template: "$1 baz") + == "$1 baz" + ) + } + + @Test + func invalidRegularExpressionReportsInvalidAndNoMatches() { + let source = "hello world" as NSString + let matcher = FindInFileMatcher( + query: "a(", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(!matcher.isValid) + #expect(matcher.matchRanges(in: source).isEmpty) + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 5), template: "$1") + == "$1" + ) + } + + @Test + func emptyQueryYieldsNoMatches() { + let source = "hello world" as NSString + + #expect(FindInFileMatcher(query: "", options: defaultOptions).matchRanges(in: source).isEmpty) + #expect( + FindInFileMatcher(query: "", options: FindInFileOptions(regularExpression: true)) + .matchRanges(in: source).isEmpty + ) + } + + @Test + func zeroWidthRegexMatchesAreSkipped() { + let source = "bab" as NSString + let matcher = FindInFileMatcher( + query: "a*", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 1, length: 1)]) + } + + @Test + func regexWithWholeWordsWrapsPatternInWordBoundaries() { + let source = "cat catalog concat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true, regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 3)]) + } + + @Test + func subRangeEnumerationSeesContextOutsideTheRange() { + // 窗口外相邻字符必须参与判定:范围首部的 cat 前一个字符在范围之外 + let source = "zcat cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source, range: NSRange(location: 1, length: 3)).isEmpty) + #expect(matcher.matchRanges(in: source, range: NSRange(location: 5, length: 3)) == [NSRange(location: 5, length: 3)]) + } + + @Test + func literalScanProducesNonOverlappingMatches() { + let source = "aaaa" as NSString + let matcher = FindInFileMatcher(query: "aa", options: defaultOptions) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 2), NSRange(location: 2, length: 2)]) + } +} diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 1ccb9a4d6..5f03ede0a 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 31) + #expect(commands.count == 32) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 00efa2175..51775c3bf 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2264,8 +2264,8 @@ struct LitheCoreLogicTests { updates.append((index, count)) } - textView.syncFindState(isVisible: true, query: "") - textView.syncFindState(isVisible: true, query: "") + textView.syncFindState(isVisible: true, query: "", options: .default) + textView.syncFindState(isVisible: true, query: "", options: .default) #expect(updates.count == 1) #expect(updates.first?.index == -1) @@ -2317,7 +2317,7 @@ struct LitheCoreLogicTests { let textView = CodeTextView(frame: .zero) textView.string = "alpha beta alpha" textView.rebuildLineIndex() - textView.updateFindMatches(query: "alpha") + textView.updateFindMatches(query: "alpha", options: .default) #expect(textView.currentFindMatchCountForTesting == 2) textView.string = "Xalpha beta alpha" @@ -2336,9 +2336,9 @@ struct LitheCoreLogicTests { reportedStates.append("\(index):\(count)") } - textView.syncFindState(isVisible: true, query: "") - textView.syncFindState(isVisible: true, query: "alpha") - textView.syncFindState(isVisible: true, query: "alpha") + textView.syncFindState(isVisible: true, query: "", options: .default) + textView.syncFindState(isVisible: true, query: "alpha", options: .default) + textView.syncFindState(isVisible: true, query: "alpha", options: .default) #expect(reportedStates == ["-1:0", "0:2"]) } From 0314740efd61c46c07360afd3fb5aae861c201f6 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 14:09:03 +0800 Subject: [PATCH 2/6] fix(macos): address review feedback on editor find replace - Unify replacement template docs and comments on $n numeric capture groups; ${name} named-group templates are not expanded by the current SDK and return verbatim - Bind replace notifications to the active document: notifications now carry documentID and CodeTextView ignores mismatched targets so split editors never replace the wrong file - Recompute matches across the whole document for regex queries where matches can span lines; keep the line-window incremental path for single-line literal queries - Add regression tests for cross-line regex matches and notification document gating --- .../2026-08-29-editor-find-replace-design.md | 2 +- ...026-08-30-editor-find-replace-tech-plan.md | 6 ++ .../Models/AppModel/AppModel+FindInFile.swift | 13 +++- .../AppModel/AppModelSupportTypes.swift | 2 + .../Models/Editor/FindInFileMatcher.swift | 6 +- .../Lithe/Views/Editor/CodeEditorView.swift | 18 ++++++ .../LitheTests/LitheCoreLogicTests.swift | 64 +++++++++++++++++++ 7 files changed, 105 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md b/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md index 66873e607..b4e8a2e96 100644 --- a/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md +++ b/docs/superpowers/specs/2026-08-29-editor-find-replace-design.md @@ -35,7 +35,7 @@ GitHub Issue [#327](https://github.com/1lck/Lithe-IDEA/issues/327) 要求补全 - Replace:替换当前匹配并自动跳到下一处匹配;跳过替换文本中新产生的匹配,避免与替换结果死循环。 - Replace All:一次性替换全部匹配,整个操作只产生一个撤销步骤,撤销后恢复原文。 - 替换完成后匹配列表、计数和高亮立即按新文本刷新。 -- 正则模式下替换模板按 `NSRegularExpression` 语义展开(`$1`、`${name}`);字面量模式下替换文本原样使用。 +- 正则模式下替换模板按 `NSRegularExpression` 语义展开(`$n` 数字捕获组,如 `$1`);`${name}` 命名分组模板当前 SDK 不支持,按原样返回;字面量模式下替换文本原样使用。 ### 入口与快捷键 diff --git a/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md b/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md index e86c47d7f..2a88f8d90 100644 --- a/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md +++ b/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md @@ -53,6 +53,9 @@ ## 编辑器替换管线 +- 替换通知携带 `activeDocument.id`(`FindNotificationKeys.documentID`);`CodeTextView` 记录自身绑定的 + `documentID`,收到 `litheFindReplaceNext` / `litheFindReplaceAll` 时先校验目标文档,分栏下非当前 + 编辑器直接忽略,避免误伤其他文件。 - 替换下一处:`insertText(_:replacementRange:)` 进入标准输入管线(撤销、`shouldChangeText` 委托、装饰刷新一致); 完成后选中替换区之后的第一个匹配,跳过替换文本自身新产生的匹配;没有更靠后的匹配时从文档开头回绕, 仍跳过与替换区重叠的匹配。 @@ -68,6 +71,9 @@ 并移除所有与窗口相交的旧匹配后重新枚举。扩一个字符的原因:行首/行尾匹配的全词边界落在相邻行, 编辑相邻行的首尾字符会改变其合法性,只有窗口覆盖到该字符才能移除并重算。 +例外:正则模式可能产生跨行匹配,行窗口增量无法覆盖(匹配起点在窗口之外时找不回来), +因此正则模式下 `applyFindEdit` 直接整篇重算;字面量查询来自单行输入框,不可能跨行,仍走行窗口优化。 + ## 命令与菜单 - `replace-in-file` 加入 `LitheCommandCatalog`(Navigation 组,默认 Cmd+R),可在 Keymap 设置中自定义; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift index a18a78cb4..f81def968 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift @@ -93,18 +93,27 @@ extension AppModel { } func replaceNextFindMatch() { + guard let documentID = activeDocument?.id else { return } + // 携带目标文档标识:分栏时只有绑定同一文档的编辑器执行替换 NotificationCenter.default.post( name: .litheFindReplaceNext, object: nil, - userInfo: [FindNotificationKeys.replacement: editorChrome.findReplaceText] + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: editorChrome.findReplaceText + ] ) } func replaceAllFindMatches() { + guard let documentID = activeDocument?.id else { return } NotificationCenter.default.post( name: .litheFindReplaceAll, object: nil, - userInfo: [FindNotificationKeys.replacement: editorChrome.findReplaceText] + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: editorChrome.findReplaceText + ] ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index f8325bd03..16157e9ec 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -87,6 +87,8 @@ enum FindNotificationKeys { static let wholeWords = "wholeWords" static let regularExpression = "regularExpression" static let replacement = "replacement" + /// 替换通知的目标文档标识;接收编辑器必须与之匹配才执行替换。 + static let documentID = "documentID" } extension Notification.Name { diff --git a/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift index e8fd9f781..2e11a3491 100644 --- a/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift +++ b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift @@ -55,8 +55,8 @@ struct FindInFileMatcher { return literalMatchRanges(in: source, range: range) } - /// 展开替换模板:正则模式按 NSRegularExpression 语义($1、${name}), - /// 字面量模式原样返回。匹配列表与当前文本不一致时按字面量处理。 + /// 展开替换模板:正则模式按 NSRegularExpression 语义展开 $n 数字捕获组 + /// (${name} 命名分组模板当前 SDK 不支持,原样返回),字面量模式原样返回。匹配列表与当前文本不一致时按字面量处理。 func replacement(for source: NSString, matchRange: NSRange, template: String) -> String { guard let expression else { return template } let searchRange = NSRange( @@ -67,7 +67,7 @@ struct FindInFileMatcher { match.range == matchRange else { return template } - // offset 仅锚定模板中的 \G;$n / ${name} 展开不受影响 + // offset 仅锚定模板中的 \G;$n 数字分组展开不受影响 return expression.replacementString(for: match, in: source as String, offset: 0, template: template) } diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index fa45158a0..f26492a13 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -539,6 +539,7 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { + codeTextView.documentID = document.id let findVisible = chrome.isFindBarVisible let findQuery = chrome.findBarQuery let findOptions = chrome.findOptions @@ -1503,6 +1504,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var currentFindMatchIndex = 0 private var lastReportedFindState: (index: Int, count: Int)? private var findMatcher = FindInFileMatcher(query: "", options: .default) + /// 本视图绑定的文档标识;替换通知只在与之匹配时生效,防止分栏误伤。 + var documentID: UUID? private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -1708,6 +1711,12 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { ? findMatcher : FindInFileMatcher(query: query, options: findMatcher.options) findMatcher = matcher + if matcher.options.regularExpression { + // 正则可能产生跨行匹配,编辑行附近的增量窗口覆盖不了, + // 直接整篇重算,避免无关位置编辑后丢失跨行匹配。 + updateFindMatches(query: query, options: matcher.options) + return + } let source = string as NSString let delta = insertedLength - replacedRange.length let replacedEnd = NSMaxRange(replacedRange) @@ -3114,17 +3123,26 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } @objc private func handleFindReplaceNext(_ notification: Notification) { + guard isReplaceNotificationTarget(notification) else { return } replaceNextFindMatch( replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" ) } @objc private func handleFindReplaceAll(_ notification: Notification) { + guard isReplaceNotificationTarget(notification) else { return } replaceAllFindMatches( replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" ) } + /// 替换通知只在绑定同一文档的编辑器上执行,避免分栏时误伤其他编辑器。 + private func isReplaceNotificationTarget(_ notification: Notification) -> Bool { + guard let targetID = notification.userInfo?[FindNotificationKeys.documentID] as? UUID + else { return false } + return targetID == documentID + } + @objc private func handleFindNavigate(_ notification: Notification) { let direction = notification.userInfo?[FindNotificationKeys.direction] as? Int ?? 1 navigateFind(offset: direction) diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 51775c3bf..438a8a173 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2343,6 +2343,70 @@ struct LitheCoreLogicTests { #expect(reportedStates == ["-1:0", "0:2"]) } + @Test + @MainActor + func codeEditorKeepsCrossLineRegexMatchesAcrossEdits() { + // 正则可能产生跨行匹配:在匹配所在行附近编辑无关内容后, + // 整篇重算必须找回该匹配(行窗口增量曾把它移除且无法在窗口内复原)。 + let textView = CodeTextView(frame: .zero) + textView.string = "alpha\nbeta gamma" + textView.rebuildLineIndex() + let options = FindInFileOptions(regularExpression: true) + textView.updateFindMatches(query: "a\\nb", options: options) + #expect(textView.currentFindMatchCountForTesting == 1) + #expect(textView.findMatchLocationsForTesting == [4]) + + textView.string = "alpha\nbeXta gamma" + textView.applyFindEdit( + replacedRange: NSRange(location: 8, length: 0), + insertedLength: 1, + query: "a\\nb" + ) + #expect(textView.currentFindMatchCountForTesting == 1) + #expect(textView.findMatchLocationsForTesting == [4]) + } + + @Test + @MainActor + func replaceNotificationsOnlyApplyToTheBoundDocument() { + let textView = CodeTextView(frame: .zero) + let documentID = UUID() + textView.documentID = documentID + textView.string = "foo bar" + textView.updateFindMatches(query: "foo", options: .default) + + // 文档不匹配的替换通知必须被忽略,防止分栏时误伤其他编辑器 + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: UUID(), + FindNotificationKeys.replacement: "baz" + ] + ) + #expect(textView.string == "foo bar") + + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: "baz" + ] + ) + #expect(textView.string == "baz bar") + + NotificationCenter.default.post( + name: .litheFindReplaceAll, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: UUID(), + FindNotificationKeys.replacement: "qux" + ] + ) + #expect(textView.string == "baz bar") + } + @Test func doubleShiftRecognizerRequiresTwoStandaloneTaps() { var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) From 3fa9f8b787ade4f071d7d5b4dc1ab4dd06d0c95b Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 12:50:30 +0800 Subject: [PATCH 3/6] feat(macos): add editor go-to-line jump Add three mouse-reachable entries for jumping to a line: the Navigate menu, the editor context menu, and the status bar caret label, all opening a shared go-to-line bar styled after the find bar. Input accepts 1-based line or line:column, converges out-of-range values against the live document, selects the target line through the existing editorNavigationTarget pathway (now carrying selectsWholeLine), and records navigation history so Cmd+[ returns to the departure position. The bar and the find bar are mutually exclusive; Cmd+L is registered in the command catalog and remappable in Keymap settings. --- .../NavigationHistoryFeatureModel.swift | 7 +- macos/Sources/Lithe/LitheApp.swift | 6 + .../AppModel/AppModel+Development.swift | 15 ++- .../AppModel/AppModel+FeatureState.swift | 2 +- .../Models/AppModel/AppModel+GoToLine.swift | 35 ++++++ .../Lithe/Models/AppModel/AppModel.swift | 1 + .../AppModel/AppModelSupportTypes.swift | 1 + .../Models/Editor/EditorChromeModel.swift | 14 +++ .../Lithe/Models/Editor/GoToLineInput.swift | 39 ++++++ .../Models/Java/JavaNavigationModels.swift | 2 + .../Models/Keymap/LitheCommandCatalog.swift | 1 + macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../Lithe/Views/Editor/CodeEditorView.swift | 29 ++++- .../Lithe/Views/Editor/EditorAreaView.swift | 3 + .../Lithe/Views/Editor/GoToLineBarView.swift | 111 ++++++++++++++++++ .../Views/Editor/StandaloneEditorView.swift | 3 + .../Workbench/WorkbenchStatusViews.swift | 14 ++- .../Lithe/Views/Workbench/WorkbenchView.swift | 8 +- .../LitheTests/EditorChromeModelTests.swift | 28 +++++ .../Tests/LitheTests/GoToLineInputTests.swift | 92 +++++++++++++++ 20 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift create mode 100644 macos/Sources/Lithe/Models/Editor/GoToLineInput.swift create mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift create mode 100644 macos/Tests/LitheTests/GoToLineInputTests.swift diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1196dc253..22919afae 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,6 +8,9 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? + /// 消费该位置时整行选中目标行(Go to Line 行为);符号与查找导航 + /// 保持零长度光标。 + let selectsWholeLine: Bool init( url: URL, @@ -15,7 +18,8 @@ struct EditorNavigationLocation: Hashable, Sendable { utf16Column: Int, isReadOnly: Bool = false, displayPath: String? = nil, - virtualProviderID: String? = nil + virtualProviderID: String? = nil, + selectsWholeLine: Bool = false ) { self.url = url.isFileURL ? url.standardizedFileURL : url self.line = max(0, line) @@ -23,6 +27,7 @@ struct EditorNavigationLocation: Hashable, Sendable { self.isReadOnly = isReadOnly self.displayPath = displayPath self.virtualProviderID = virtualProviderID + self.selectsWholeLine = selectsWholeLine } } diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index a420e07f0..0c8a2fb6d 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -352,6 +352,12 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) + + Button("Go to Line…") { + model.showGoToLineBar() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) + .disabled(model.activeDocument == nil) } Divider() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index d04d89c46..9b4283ac8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -759,7 +759,8 @@ extension AppModel { line: Int, utf16Column: Int, isReadOnly: Bool = false, - displayPath: String? = nil + displayPath: String? = nil, + selectsWholeLine: Bool = false ) { navigate( to: EditorNavigationLocation( @@ -768,7 +769,8 @@ extension AppModel { utf16Column: utf16Column, isReadOnly: isReadOnly, displayPath: displayPath, - virtualProviderID: nil + virtualProviderID: nil, + selectsWholeLine: selectsWholeLine ), recordsHistory: true ) @@ -789,7 +791,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) return } @@ -831,7 +834,8 @@ extension AppModel { self.editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) case .failure(let error): onFailure?() @@ -858,7 +862,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url.standardizedFileURL, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 6cb844336..5ffa13aa1 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -330,7 +330,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "replace-in-file", "local-history", "reveal-in-finder": + case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift new file mode 100644 index 000000000..1e1e9c9b4 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -0,0 +1,35 @@ +import Foundation + +/// AppModel 的按行号跳转门面:驱动 `EditorChromeModel` 的跳转条显隐, +/// 并把状态栏、菜单和快捷键入口提交的“行:列”输入经现有导航通路 +/// `navigateToEditorLocation` 跳转到目标行,进入既有导航历史。 +extension AppModel { + var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } + + func showGoToLineBar() { + guard activeDocument != nil else { return } + if isFindBarVisible { + hideFindBar() + } + editorChrome.setGoToLineVisible(true) + } + + func hideGoToLineBar() { + editorChrome.setGoToLineVisible(false) + } + + /// 解析“120”或“120:35”输入并跳转,解析失败或无活动文档时为无操作。 + /// 跳转前用当前文档文本重新收敛行列,不缓存打开输入框时的行数; + /// 跳转进入导航历史,Cmd+[ 可以回到跳转前的位置。 + func goToLine(_ text: String) { + guard let document = activeDocument, + let parsed = GoToLineInput.parse(text) else { return } + let target = GoToLineInput.clamped(line: parsed.line, column: parsed.column, in: document.text) + navigateToEditorLocation( + url: document.url, + line: target.line, + utf16Column: target.column, + selectsWholeLine: true + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 092848fb5..36ddd67c0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1053,6 +1053,7 @@ final class AppModel: ObservableObject, Identifiable { standaloneFileURL = nil documentFeature.reset() editorChrome.resetFindBar() + editorChrome.setGoToLineVisible(false) didCloseProject?() } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 16157e9ec..70cf42cdd 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -97,6 +97,7 @@ extension Notification.Name { static let litheFindDismiss = Notification.Name("litheFindDismiss") static let litheFindReplaceNext = Notification.Name("litheFindReplaceNext") static let litheFindReplaceAll = Notification.Name("litheFindReplaceAll") + static let litheGoToLineDismiss = Notification.Name("litheGoToLineDismiss") } struct ProjectTreeRevealRequest: Equatable { diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 9931bc4b5..26b3d3be4 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -9,6 +9,7 @@ final class EditorChromeModel: ObservableObject { @Published private(set) var caret: EditorCaret? @Published private(set) var selectedText = "" @Published private(set) var isFindBarVisible = false + @Published private(set) var isGoToLineVisible = false @Published private(set) var findBarQuery = "" @Published private(set) var findOptions = FindInFileOptions() @Published private(set) var isReplaceVisible = false @@ -29,6 +30,18 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible + // 查找栏与跳转条互斥,任一打开都会收起另一个 + if isVisible, isGoToLineVisible { + setGoToLineVisible(false) + } + } + + func setGoToLineVisible(_ isVisible: Bool) { + guard isGoToLineVisible != isVisible else { return } + isGoToLineVisible = isVisible + if isVisible, isFindBarVisible { + setFindBarVisible(false) + } } func setFindBarQuery(_ query: String) { @@ -70,5 +83,6 @@ final class EditorChromeModel: ObservableObject { update(caret: nil) update(selectedText: "") resetFindBar() + setGoToLineVisible(false) } } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift new file mode 100644 index 000000000..223012352 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -0,0 +1,39 @@ +import Foundation + +/// “Go to Line”输入的解析与文档范围收敛。输入是 1-based 的“120”或 +/// “120:35”文本,解析结果为内部 0-based 行列;1-based → 0-based 的换算 +/// 只发生在这里,状态栏显示时再 +1,避免多处偏移。 +struct GoToLineInput: Equatable { + let line: Int + let column: Int + + /// 解析“120”、“120:35”或两侧带空格的等价输入;空串、非数字、多冒号、 + /// 0 与负数都视为非法,返回 `nil` 表示本次跳转应为无操作。 + static func parse(_ text: String) -> GoToLineInput? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 1 || parts.count == 2 else { return nil } + guard let line = oneBasedNumber(in: parts[0]) else { return nil } + let column = parts.count == 2 ? oneBasedNumber(in: parts[1]) : 1 + guard let column else { return nil } + return GoToLineInput(line: line - 1, column: column - 1) + } + + /// 将 0-based 行列收敛到文档内容范围内:行超出收敛到最后一行,列超出 + /// 收敛到行尾,负值收敛到 0;空文档只定位到文档开头。跳转前必须用 + /// 当前文档文本重新收敛,不做陈旧行数缓存。 + static func clamped(line: Int, column: Int, in content: String) -> GoToLineInput { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false) + let clampedLine = min(max(line, 0), lines.count - 1) + let lineLength = lines[clampedLine].utf16.count + return GoToLineInput(line: clampedLine, column: min(max(column, 0), lineLength)) + } + + private static func oneBasedNumber(in part: Substring) -> Int? { + guard let value = Int(part.trimmingCharacters(in: .whitespaces)), value >= 1 else { + return nil + } + return value + } +} diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755d9fb37..755fa4548 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,6 +12,8 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int + /// 整行选中目标行(Go to Line 行为);符号与查找导航保持零长度光标。 + var selectsWholeLine: Bool = false } struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 521e44df7..a19e9868e 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -38,6 +38,7 @@ enum LitheCommandCatalog { command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), command("replace-in-file", "Replace in File", "Replace within the active editor", .navigation, "r", [.command]), + command("go-to-line", "Go to Line", "Jump to a line and column in the active editor", .navigation, "l", [.command]), command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 4cf06a432..b355ac1d7 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -81,6 +81,7 @@ enum LitheActionRegistry { action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, action("replace-in-file", model: model) { model.showReplaceBar() }, + action("go-to-line", model: model) { model.showGoToLineBar() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index f26492a13..2c83e3d94 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -395,6 +395,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLineBar() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in @@ -1327,7 +1328,16 @@ struct CodeEditorView: NSViewRepresentable { } let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) - textView.setSelectedRange(NSRange(location: location, length: 0)) + if target.selectsWholeLine { + // Go to Line 的落点反馈:整行选中目标行,行尾换行符不计入选区 + var selectionLength = NSMaxRange(lineRange) - lineStart + if selectionLength > 0, text.character(at: NSMaxRange(lineRange) - 1) == 10 { + selectionLength -= 1 + } + textView.setSelectedRange(NSRange(location: lineStart, length: selectionLength)) + } else { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } textView.scrollRangeToVisible(NSRange(location: location, length: 0)) textView.window?.makeFirstResponder(textView) scheduleCaretUpdate() @@ -1489,6 +1499,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onGoToImplementation: (() -> Void)? var onFindUsages: (() -> Void)? var onFindRequested: (() -> Void)? + var onGoToLineRequested: (() -> Void)? var onFindNextRequested: (() -> Void)? var onFindPreviousRequested: (() -> Void)? var onFindStateChange: ((Int, Int) -> Void)? @@ -2470,6 +2481,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } override func mouseDown(with event: NSEvent) { + // 点击编辑器任意位置都收起跳转条;点击本身仍交给编辑器处理 + NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) let point = convert(event.locationInWindow, from: nil) if let region = foldSummaryRegion(at: point) { onToggleFold?(region) @@ -2862,6 +2875,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } let menu = super.menu(for: event) ?? NSMenu() + let goToLineItem = NSMenuItem( + title: "Go to Line…", + action: #selector(goToLineFromMenu), + keyEquivalent: "" + ) + goToLineItem.target = self + menu.insertItem(goToLineItem, at: 0) + menu.insertItem(.separator(), at: 1) let languageItems = languageContextMenuItems() guard !languageItems.isEmpty else { return menu } menu.insertItem(.separator(), at: 0) @@ -2892,6 +2913,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { onGoToDefinition?() } + @objc private func goToLineFromMenu() { + onGoToLineRequested?() + } + @objc private func showQuickDocumentationFromMenu() { let position = languageServerPosition(at: selectedRange().location) onQuickDocumentation?(position.line, position.utf16Column) @@ -3908,6 +3933,8 @@ final class LineNumberGutterView: NSView { } override func mouseDown(with event: NSEvent) { + // 行号栏也属于编辑器区域,点击时同样收起跳转条 + NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) guard let textView, let scrollView, let layoutManager = textView.layoutManager, diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 67fdcbcd0..5a6a0629a 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -1151,6 +1151,9 @@ struct EditorAreaView: View { .overlay(alignment: .top) { FindBarOverlay() } + .overlay(alignment: .topTrailing) { + GoToLineBarOverlay() + } } private func codeEditor( diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift new file mode 100644 index 000000000..fad6d7d88 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// 编辑器内的按行号跳转条:输入 1-based 行号或“行:列”,Return 跳转并 +/// 关闭,Esc 或点击编辑器任意位置直接关闭。与查找栏互斥,显隐由 +/// `EditorChromeModel` 保证。 +struct GoToLineBarView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @FocusState private var lineFocused: Bool + @State private var lineText = "" + @State private var columnText = "" + + var body: some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: "number") + .font(.system(size: 11.5)) + .foregroundStyle(inputIsInvalid ? LitheTheme.error : LitheTheme.secondaryText) + .help("Go to line. Format: line or line:column, both 1-based") + + TextField("Line", text: $lineText) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .monospacedDigit() + .focused($lineFocused) + .frame(minWidth: 52) + + Text(":") + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.secondaryText) + + TextField("Column", text: $columnText) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .monospacedDigit() + .frame(minWidth: 52) + + if inputIsInvalid { + Text("Invalid line") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.error) + } + + Button { + model.hideGoToLineBar() + } label: { + Image(systemName: "xmark") + } + .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) + .help("Close (Esc)") + } + .padding(.horizontal, 10) + .frame(maxWidth: 320, minHeight: 34) + .lithePopupChrome(cornerRadius: 7) + .onAppear(perform: prefillFromCaret) + .onExitCommand { model.hideGoToLineBar() } + .macReturnKeyHandler(isEnabled: inputIsValid) { _ in + jump() + } + .onReceive(NotificationCenter.default.publisher(for: .litheGoToLineDismiss)) { _ in + model.hideGoToLineBar() + } + } + + private var combinedInput: String { + let line = lineText.trimmingCharacters(in: .whitespaces) + // 行输入位直接粘贴“120:35”时忽略列输入位,避免拼出多冒号 + if line.contains(":") { + return line + } + let column = columnText.trimmingCharacters(in: .whitespaces) + guard !column.isEmpty else { return line } + return "\(line):\(column)" + } + + private var inputIsValid: Bool { + GoToLineInput.parse(combinedInput) != nil + } + + /// 行号必填;只有行输入位有内容且无法解析时才提示非法, + /// 避免刚打开输入框就报错。 + private var inputIsInvalid: Bool { + !lineText.trimmingCharacters(in: .whitespaces).isEmpty && !inputIsValid + } + + private func prefillFromCaret() { + lineText = "\(max(chrome.caret?.line ?? 0, 0) + 1)" + lineFocused = true + } + + private func jump() { + guard inputIsValid else { return } + model.goToLine(combinedInput) + model.hideGoToLineBar() + } +} + +/// 跳转条的浮层挂载点:出现在编辑器区域右上,顶部滑入过渡, +/// 挂载方式对齐 EditorAreaView 的 FindBarOverlay。 +struct GoToLineBarOverlay: View { + @EnvironmentObject private var chrome: EditorChromeModel + + var body: some View { + if chrome.isGoToLineVisible { + GoToLineBarView() + .padding(.top, 10) + .padding(.trailing, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 1b0e80976..4c3fa2979 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -52,6 +52,9 @@ struct StandaloneEditorView: View { .padding(.horizontal, 12) } } + .overlay(alignment: .topTrailing) { + GoToLineBarOverlay() + } } else { failureView(.readFailed) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 1dc9a7662..d1c5a7117 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,11 +1,21 @@ import SwiftUI +/// 状态栏的行:列指示:视觉保持纯文本现状,点击打开 Go to Line 输入条 +/// (无活动文档时为无操作)。 struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel + let onShowGoToLine: () -> Void var body: some View { - Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() + Button { + onShowGoToLine() + } label: { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } + .buttonStyle(.plain) + .lithePointer() + .help("Go to Line…") } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619be..c57c788ff 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1063,7 +1063,9 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLineBar() + } Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -1084,7 +1086,9 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLineBar() + } MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 0bd6773ab..887ec44c6 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -118,4 +118,32 @@ struct EditorChromeModelTests { ) #expect(chrome.findReplaceText == "bar") } + + @Test + func goToLineBarAndFindBarAreMutuallyExclusive() { + // 查找栏与跳转条互斥:任一打开都会收起另一个 + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setGoToLineVisible(true) + #expect(chrome.isGoToLineVisible) + #expect(!chrome.isFindBarVisible) + + chrome.setFindBarVisible(true) + #expect(chrome.isFindBarVisible) + #expect(!chrome.isGoToLineVisible) + + chrome.setGoToLineVisible(false) + #expect(!chrome.isGoToLineVisible) + #expect(chrome.isFindBarVisible) + } + + @Test + func resetClosesGoToLineBar() { + let chrome = EditorChromeModel() + chrome.setGoToLineVisible(true) + + chrome.reset() + + #expect(!chrome.isGoToLineVisible) + } } diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift new file mode 100644 index 000000000..a2bc189b9 --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import Lithe + +struct GoToLineInputTests { + @Test + func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { + // “120”表示第 120 行行首,内部转换为 0-based + #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func parsesLineAndColumnInput() { + #expect(GoToLineInput.parse("120:35") == GoToLineInput(line: 119, column: 34)) + } + + @Test + func toleratesWhitespaceAroundAndBetweenNumbers() { + #expect(GoToLineInput.parse(" 120 ") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("12 : 34") == GoToLineInput(line: 11, column: 33)) + } + + @Test + func rejectsEmptyNonNumericAndMultiColonInput() { + #expect(GoToLineInput.parse("") == nil) + #expect(GoToLineInput.parse(" ") == nil) + #expect(GoToLineInput.parse("abc") == nil) + #expect(GoToLineInput.parse("12abc") == nil) + #expect(GoToLineInput.parse("1:2:3") == nil) + #expect(GoToLineInput.parse("120:") == nil) + #expect(GoToLineInput.parse(":35") == nil) + } + + @Test + func rejectsZeroAndNegativeNumbers() { + // 1-based 输入里 0 与负数都是非法值 + #expect(GoToLineInput.parse("0") == nil) + #expect(GoToLineInput.parse("-1") == nil) + #expect(GoToLineInput.parse("0:5") == nil) + #expect(GoToLineInput.parse("5:0") == nil) + #expect(GoToLineInput.parse("5:-2") == nil) + } + + @Test + func keepsInBoundsLineAndColumnUnchanged() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 2, in: content) == GoToLineInput(line: 0, column: 2)) + #expect(GoToLineInput.clamped(line: 2, column: 4, in: content) == GoToLineInput(line: 2, column: 4)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 99, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsOutOfRangeColumnToLineEnd() { + // 列按 UTF-16 单元计数,与编辑器 caret 的 utf16Column 口径一致 + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) + } + + @Test + func clampsNegativeValuesToDocumentStart() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: -3, column: -1, in: content) == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsAnyInputInEmptyDocumentToOrigin() { + // 空文档(0 行)时输入任何行号都只定位到文档开头 + #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsToTrailingEmptyLineAfterFinalNewline() { + // “a\n”在编辑器里存在可定位的第 2 行(末尾空行) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) + } + + @Test + func clampsColumnUsingUTF16LengthOfEmojiLine() { + // emoji 占 2 个 UTF-16 单元,列收敛按 UTF-16 长度而非字符数 + let content = "a\u{1F600}b" + #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) + #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) + #expect(GoToLineInput.clamped(line: 0, column: 5, in: content) == GoToLineInput(line: 0, column: 4)) + } +} From 27b66b80a0f10df67cd5a9fb8bef07addd9f9432 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 13:40:29 +0800 Subject: [PATCH 4/6] feat(macos): replace go-to-line bar with a floating dialog Swap the in-editor go-to-line bar for a small modal "Go to Line:Column" window: a single [Line] [:column]: input prefilled with the caret's 1-based position and fully selected, with Cancel/OK buttons. Return and OK jump through the same parser and navigation pathway, invalid input disables OK, and Esc, Cancel, or the close button dismiss without side effects. Every entry point (Navigate menu, editor context menu, status bar caret label, Cmd+L) funnels through the chrome visibility flag via a shared presenter, so the workbench and standalone editor windows both stay covered. Also unify all go-to-line comments to English to match the surrounding files. --- .../NavigationHistoryFeatureModel.swift | 4 +- macos/Sources/Lithe/LitheApp.swift | 2 +- .../Models/AppModel/AppModel+GoToLine.swift | 19 +- .../AppModel/AppModelSupportTypes.swift | 1 - .../Models/Editor/EditorChromeModel.swift | 3 +- .../Lithe/Models/Editor/GoToLineInput.swift | 22 +- .../Models/Java/JavaNavigationModels.swift | 3 +- macos/Sources/Lithe/Models/LitheAction.swift | 2 +- .../Lithe/Views/Editor/CodeEditorView.swift | 9 +- .../Lithe/Views/Editor/EditorAreaView.swift | 4 +- .../Lithe/Views/Editor/GoToLineBarView.swift | 111 --------- .../Lithe/Views/Editor/GoToLineDialog.swift | 216 ++++++++++++++++++ .../Views/Editor/StandaloneEditorView.swift | 4 +- .../Workbench/WorkbenchStatusViews.swift | 5 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 4 +- .../LitheTests/EditorChromeModelTests.swift | 5 +- .../Tests/LitheTests/GoToLineInputTests.swift | 14 +- 17 files changed, 270 insertions(+), 158 deletions(-) delete mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift create mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 22919afae..1007e28fc 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,8 +8,8 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? - /// 消费该位置时整行选中目标行(Go to Line 行为);符号与查找导航 - /// 保持零长度光标。 + /// Consume the location with the whole target line selected (Go to Line); + /// symbol and find navigation keep a zero-length caret. let selectsWholeLine: Bool init( diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 0c8a2fb6d..8eb2d1f41 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -354,7 +354,7 @@ struct LitheApp: App { .disabled(!model.isFindBarVisible || model.findMatchCount == 0) Button("Go to Line…") { - model.showGoToLineBar() + model.showGoToLine() } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) .disabled(model.activeDocument == nil) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift index 1e1e9c9b4..f8075c705 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -1,12 +1,13 @@ import Foundation -/// AppModel 的按行号跳转门面:驱动 `EditorChromeModel` 的跳转条显隐, -/// 并把状态栏、菜单和快捷键入口提交的“行:列”输入经现有导航通路 -/// `navigateToEditorLocation` 跳转到目标行,进入既有导航历史。 +/// AppModel facade for the Go to Line feature: drives the `EditorChromeModel` +/// visibility flag that the dialog presenter observes, and routes the +/// submitted "line" or "line:column" input through the existing +/// `navigateToEditorLocation` pathway so jumps enter the navigation history. extension AppModel { var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } - func showGoToLineBar() { + func showGoToLine() { guard activeDocument != nil else { return } if isFindBarVisible { hideFindBar() @@ -14,13 +15,15 @@ extension AppModel { editorChrome.setGoToLineVisible(true) } - func hideGoToLineBar() { + func hideGoToLine() { editorChrome.setGoToLineVisible(false) } - /// 解析“120”或“120:35”输入并跳转,解析失败或无活动文档时为无操作。 - /// 跳转前用当前文档文本重新收敛行列,不缓存打开输入框时的行数; - /// 跳转进入导航历史,Cmd+[ 可以回到跳转前的位置。 + /// Parse "120" or "120:35" and jump; unparseable input or a missing + /// active document is a no-op. Line and column are converged against the + /// current document text right before jumping, without caching stale + /// line counts, and the jump enters the navigation history so Cmd+[ + /// returns to the departure position. func goToLine(_ text: String) { guard let document = activeDocument, let parsed = GoToLineInput.parse(text) else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 70cf42cdd..16157e9ec 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -97,7 +97,6 @@ extension Notification.Name { static let litheFindDismiss = Notification.Name("litheFindDismiss") static let litheFindReplaceNext = Notification.Name("litheFindReplaceNext") static let litheFindReplaceAll = Notification.Name("litheFindReplaceAll") - static let litheGoToLineDismiss = Notification.Name("litheGoToLineDismiss") } struct ProjectTreeRevealRequest: Equatable { diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 26b3d3be4..2e81a82a0 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -30,7 +30,8 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible - // 查找栏与跳转条互斥,任一打开都会收起另一个 + // The find bar and the go-to-line dialog are mutually exclusive; + // opening either dismisses the other. if isVisible, isGoToLineVisible { setGoToLineVisible(false) } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift index 223012352..0386bbec6 100644 --- a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -1,14 +1,16 @@ import Foundation -/// “Go to Line”输入的解析与文档范围收敛。输入是 1-based 的“120”或 -/// “120:35”文本,解析结果为内部 0-based 行列;1-based → 0-based 的换算 -/// 只发生在这里,状态栏显示时再 +1,避免多处偏移。 +/// Parsing and document-range convergence for the Go to Line input. The +/// input is 1-based text — "120" or "120:35" — and the parsed result is the +/// internal 0-based line and column. The 1-based → 0-based conversion happens +/// only here; the status bar adds 1 back for display, avoiding split offsets. struct GoToLineInput: Equatable { let line: Int let column: Int - /// 解析“120”、“120:35”或两侧带空格的等价输入;空串、非数字、多冒号、 - /// 0 与负数都视为非法,返回 `nil` 表示本次跳转应为无操作。 + /// Parses "120", "120:35", or whitespace-padded equivalents. Empty input, + /// non-numeric text, multiple colons, zero, and negative values are all + /// invalid and yield `nil`, making the jump a no-op. static func parse(_ text: String) -> GoToLineInput? { let trimmed = text.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } @@ -20,9 +22,13 @@ struct GoToLineInput: Equatable { return GoToLineInput(line: line - 1, column: column - 1) } - /// 将 0-based 行列收敛到文档内容范围内:行超出收敛到最后一行,列超出 - /// 收敛到行尾,负值收敛到 0;空文档只定位到文档开头。跳转前必须用 - /// 当前文档文本重新收敛,不做陈旧行数缓存。 + /// Converges 0-based line and column into the given document content: + /// an out-of-range line collapses to the last line, an out-of-range + /// column to the end of that line (counted in UTF-16 units to match + /// `EditorCaret.utf16Column`), negatives to the origin. An empty document + /// only ever addresses the document start. Callers must re-converge with + /// the live document text right before jumping; line counts are never + /// cached. static func clamped(line: Int, column: Int, in content: String) -> GoToLineInput { let lines = content.split(separator: "\n", omittingEmptySubsequences: false) let clampedLine = min(max(line, 0), lines.count - 1) diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755fa4548..a8f4d624e 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,7 +12,8 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int - /// 整行选中目标行(Go to Line 行为);符号与查找导航保持零长度光标。 + /// Select the whole target line on arrival (Go to Line); symbol and find + /// navigation keep a zero-length caret. var selectsWholeLine: Bool = false } diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index b355ac1d7..7949535c9 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -81,7 +81,7 @@ enum LitheActionRegistry { action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, action("replace-in-file", model: model) { model.showReplaceBar() }, - action("go-to-line", model: model) { model.showGoToLineBar() }, + action("go-to-line", model: model) { model.showGoToLine() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 2c83e3d94..857ed01c3 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -395,7 +395,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } - textView.onGoToLineRequested = { [weak model] in model?.showGoToLineBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLine() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in @@ -1329,7 +1329,8 @@ struct CodeEditorView: NSViewRepresentable { let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) if target.selectsWholeLine { - // Go to Line 的落点反馈:整行选中目标行,行尾换行符不计入选区 + // Go to Line feedback: select the whole target line, excluding + // the trailing newline so the selection is pure line content. var selectionLength = NSMaxRange(lineRange) - lineStart if selectionLength > 0, text.character(at: NSMaxRange(lineRange) - 1) == 10 { selectionLength -= 1 @@ -2481,8 +2482,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } override func mouseDown(with event: NSEvent) { - // 点击编辑器任意位置都收起跳转条;点击本身仍交给编辑器处理 - NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) let point = convert(event.locationInWindow, from: nil) if let region = foldSummaryRegion(at: point) { onToggleFold?(region) @@ -3933,8 +3932,6 @@ final class LineNumberGutterView: NSView { } override func mouseDown(with event: NSEvent) { - // 行号栏也属于编辑器区域,点击时同样收起跳转条 - NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) guard let textView, let scrollView, let layoutManager = textView.layoutManager, diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 5a6a0629a..e276cc983 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -94,6 +94,7 @@ struct EditorAreaView: View { } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .background(GoToLineDialogPresenter()) .onChange(of: model.openDocuments.map(\.id)) { ids in if let splitDocumentID, !ids.contains(splitDocumentID) { self.splitDocumentID = nil @@ -1151,9 +1152,6 @@ struct EditorAreaView: View { .overlay(alignment: .top) { FindBarOverlay() } - .overlay(alignment: .topTrailing) { - GoToLineBarOverlay() - } } private func codeEditor( diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift deleted file mode 100644 index fad6d7d88..000000000 --- a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI - -/// 编辑器内的按行号跳转条:输入 1-based 行号或“行:列”,Return 跳转并 -/// 关闭,Esc 或点击编辑器任意位置直接关闭。与查找栏互斥,显隐由 -/// `EditorChromeModel` 保证。 -struct GoToLineBarView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var chrome: EditorChromeModel - @FocusState private var lineFocused: Bool - @State private var lineText = "" - @State private var columnText = "" - - var body: some View { - HStack(spacing: 7) { - LitheSystemIcon(systemImage: "number") - .font(.system(size: 11.5)) - .foregroundStyle(inputIsInvalid ? LitheTheme.error : LitheTheme.secondaryText) - .help("Go to line. Format: line or line:column, both 1-based") - - TextField("Line", text: $lineText) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - .monospacedDigit() - .focused($lineFocused) - .frame(minWidth: 52) - - Text(":") - .font(.system(size: 12.5)) - .foregroundStyle(LitheTheme.secondaryText) - - TextField("Column", text: $columnText) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - .monospacedDigit() - .frame(minWidth: 52) - - if inputIsInvalid { - Text("Invalid line") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.error) - } - - Button { - model.hideGoToLineBar() - } label: { - Image(systemName: "xmark") - } - .litheIconButton() - .foregroundStyle(LitheTheme.secondaryText) - .help("Close (Esc)") - } - .padding(.horizontal, 10) - .frame(maxWidth: 320, minHeight: 34) - .lithePopupChrome(cornerRadius: 7) - .onAppear(perform: prefillFromCaret) - .onExitCommand { model.hideGoToLineBar() } - .macReturnKeyHandler(isEnabled: inputIsValid) { _ in - jump() - } - .onReceive(NotificationCenter.default.publisher(for: .litheGoToLineDismiss)) { _ in - model.hideGoToLineBar() - } - } - - private var combinedInput: String { - let line = lineText.trimmingCharacters(in: .whitespaces) - // 行输入位直接粘贴“120:35”时忽略列输入位,避免拼出多冒号 - if line.contains(":") { - return line - } - let column = columnText.trimmingCharacters(in: .whitespaces) - guard !column.isEmpty else { return line } - return "\(line):\(column)" - } - - private var inputIsValid: Bool { - GoToLineInput.parse(combinedInput) != nil - } - - /// 行号必填;只有行输入位有内容且无法解析时才提示非法, - /// 避免刚打开输入框就报错。 - private var inputIsInvalid: Bool { - !lineText.trimmingCharacters(in: .whitespaces).isEmpty && !inputIsValid - } - - private func prefillFromCaret() { - lineText = "\(max(chrome.caret?.line ?? 0, 0) + 1)" - lineFocused = true - } - - private func jump() { - guard inputIsValid else { return } - model.goToLine(combinedInput) - model.hideGoToLineBar() - } -} - -/// 跳转条的浮层挂载点:出现在编辑器区域右上,顶部滑入过渡, -/// 挂载方式对齐 EditorAreaView 的 FindBarOverlay。 -struct GoToLineBarOverlay: View { - @EnvironmentObject private var chrome: EditorChromeModel - - var body: some View { - if chrome.isGoToLineVisible { - GoToLineBarView() - .padding(.top, 10) - .padding(.trailing, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } -} diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift new file mode 100644 index 000000000..d6f2f16f4 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -0,0 +1,216 @@ +import AppKit +import SwiftUI + +/// “Go to Line:Column”dialog: a small floating window with a single +/// `[Line] [:column]:` input that accepts a 1-based line or line:column, +/// prefilled with the current caret position and fully selected. Return or +/// OK jumps, Esc or Cancel dismisses without side effects. Presentation is +/// a view-layer capability: visibility state lives in `EditorChromeModel` +/// and the jump itself goes through the existing `AppModel` navigation path. +@MainActor +enum GoToLineDialog { + private static let okResponse = NSApplication.ModalResponse(rawValue: 1) + private static let cancelResponse = NSApplication.ModalResponse(rawValue: 0) + /// Both the workbench and the standalone editor window host a presenter + /// observing the same chrome flag; this keeps only one modal alive. + private static var isPresented = false + + /// Present the dialog modally over the editor window; on OK, parse the + /// input and jump. Invalid input keeps Return from jumping. + static func present(model: AppModel) { + guard !isPresented, model.activeDocument != nil else { return } + isPresented = true + defer { isPresented = false } + model.showGoToLine() + + let coordinator = DialogCoordinator() + coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } + coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } + let panel = makePanel(coordinator: coordinator) + configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) + center(panel: panel) + panel.makeKeyAndOrderFront(nil) + if let field = coordinator.field { + panel.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + let response = NSApp.runModal(for: panel) + panel.orderOut(nil) + + model.hideGoToLine() + if response == okResponse, let input = coordinator.confirmedText { + model.goToLine(input) + } + } + + /// Prefill mirrors the status bar's 1-based line:column display. + private static func initialValue(for model: AppModel) -> String { + let caret = model.editorChrome.caret + return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" + } + + private static func makePanel(coordinator: DialogCoordinator) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = "Go to Line:Column" + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.delegate = coordinator + return panel + } + + private static func configureContent( + panel: NSPanel, + coordinator: DialogCoordinator, + initialValue: String + ) { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 96)) + + let label = NSTextField(labelWithString: "[Line] [:column]:") + label.font = .systemFont(ofSize: 13) + label.sizeToFit() + label.frame.origin = NSPoint(x: 16, y: 50) + content.addSubview(label) + + let field = NSTextField(frame: NSRect( + x: label.frame.maxX + 8, + y: 48, + width: 340 - label.frame.width - 16 - 8 - 16, + height: 24 + )) + field.stringValue = initialValue + field.font = .systemFont(ofSize: 13) + field.delegate = coordinator + field.target = coordinator + field.action = #selector(DialogCoordinator.confirmFromField) + coordinator.field = field + content.addSubview(field) + + let cancelButton = NSButton( + title: "Cancel", + target: coordinator, + action: #selector(DialogCoordinator.cancelFromButton) + ) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + cancelButton.frame = NSRect(x: 340 - 16 - 78 - 10 - 78, y: 12, width: 78, height: 30) + content.addSubview(cancelButton) + + let okButton = NSButton( + title: "OK", + target: coordinator, + action: #selector(DialogCoordinator.confirmFromButton) + ) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.frame = NSRect(x: 340 - 16 - 78, y: 12, width: 78, height: 30) + okButton.isEnabled = GoToLineInput.parse(initialValue) != nil + coordinator.okButton = okButton + content.addSubview(okButton) + + panel.contentView = content + } + + /// Prefer centering over the editor window so the jump origin stays visible. + private static func center(panel: NSPanel) { + let size = panel.frame.size + if let keyWindow = NSApp.keyWindow, keyWindow !== panel { + panel.setFrameOrigin( + NSPoint( + x: keyWindow.frame.midX - size.width / 2, + y: keyWindow.frame.midY - size.height / 2 + ) + ) + } else { + panel.center() + } + } +} + +@MainActor +private final class DialogCoordinator: NSObject, NSWindowDelegate, NSTextFieldDelegate { + weak var field: NSTextField? + weak var okButton: NSButton? + private(set) var confirmedText: String? + var onConfirm: (() -> Void)? + var onCancel: (() -> Void)? + + func windowShouldClose(_ sender: NSWindow) -> Bool { + cancel() + return true + } + + /// Gray out OK while the input is not a valid line or line:column. + func controlTextDidChange(_ notification: Notification) { + guard let field else { return } + okButton?.isEnabled = GoToLineInput.parse(field.stringValue) != nil + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + confirmFromField() + return true + case #selector(NSResponder.cancelOperation(_:)): + cancel() + return true + default: + return false + } + } + + @objc func confirmFromField() { + confirm() + } + + @objc func confirmFromButton() { + confirm() + } + + @objc func cancelFromButton() { + cancel() + } + + private func confirm() { + guard let field, + GoToLineInput.parse(field.stringValue) != nil else { return } + confirmedText = field.stringValue + onConfirm?() + } + + private func cancel() { + onCancel?() + } +} + +/// Presents the dialog when the chrome flag flips on, so every entry point +/// (menu, context menu, status bar, Cmd+L) funnels through the same state. +/// The presentation is deferred one runloop hop so it never runs inside a +/// SwiftUI view update. +struct GoToLineDialogPresenter: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @State private var isPresenting = false + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: chrome.isGoToLineVisible) { isVisible in + guard isVisible, !isPresenting else { return } + isPresenting = true + DispatchQueue.main.async { [model] in + defer { isPresenting = false } + GoToLineDialog.present(model: model) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 4c3fa2979..505d858a4 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -14,6 +14,7 @@ struct StandaloneEditorView: View { content } .background(LitheTheme.editor) + .background(GoToLineDialogPresenter()) .confirmationDialog( "Save changes before closing?", isPresented: Binding( @@ -52,9 +53,6 @@ struct StandaloneEditorView: View { .padding(.horizontal, 12) } } - .overlay(alignment: .topTrailing) { - GoToLineBarOverlay() - } } else { failureView(.readFailed) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index d1c5a7117..7c35c8537 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,7 +1,8 @@ import SwiftUI -/// 状态栏的行:列指示:视觉保持纯文本现状,点击打开 Go to Line 输入条 -/// (无活动文档时为无操作)。 +/// Status bar line:column indicator. Visually unchanged from the plain text +/// label; clicking opens the Go to Line dialog (a no-op without an active +/// document). struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel let onShowGoToLine: () -> Void diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index c57c788ff..10eb0703d 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1064,7 +1064,7 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { EditorCaretPositionLabel(chrome: model.editorChrome) { - model.showGoToLineBar() + model.showGoToLine() } Text("UTF-8") Text("\(settings.tabWidth) spaces") @@ -1087,7 +1087,7 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { EditorCaretPositionLabel(chrome: model.editorChrome) { - model.showGoToLineBar() + model.showGoToLine() } MemoryUsageStatusView() FrameRateStatusView() diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 887ec44c6..3e5b039f0 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -120,8 +120,9 @@ struct EditorChromeModelTests { } @Test - func goToLineBarAndFindBarAreMutuallyExclusive() { - // 查找栏与跳转条互斥:任一打开都会收起另一个 + func goToLineDialogAndFindBarAreMutuallyExclusive() { + // The find bar and the go-to-line dialog are mutually exclusive: + // opening either dismisses the other. let chrome = EditorChromeModel() chrome.setFindBarVisible(true) chrome.setGoToLineVisible(true) diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift index a2bc189b9..9ea69ca87 100644 --- a/macos/Tests/LitheTests/GoToLineInputTests.swift +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -5,7 +5,7 @@ import Testing struct GoToLineInputTests { @Test func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { - // “120”表示第 120 行行首,内部转换为 0-based + // "120" means 1-based line 120, line start; converted to 0-based here. #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) } @@ -34,7 +34,7 @@ struct GoToLineInputTests { @Test func rejectsZeroAndNegativeNumbers() { - // 1-based 输入里 0 与负数都是非法值 + // In 1-based input, zero and negatives are invalid values. #expect(GoToLineInput.parse("0") == nil) #expect(GoToLineInput.parse("-1") == nil) #expect(GoToLineInput.parse("0:5") == nil) @@ -57,7 +57,8 @@ struct GoToLineInputTests { @Test func clampsOutOfRangeColumnToLineEnd() { - // 列按 UTF-16 单元计数,与编辑器 caret 的 utf16Column 口径一致 + // Columns are counted in UTF-16 units, matching the editor caret's + // utf16Column convention. let content = "first\nsecond line\nthird" #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) } @@ -70,20 +71,21 @@ struct GoToLineInputTests { @Test func clampsAnyInputInEmptyDocumentToOrigin() { - // 空文档(0 行)时输入任何行号都只定位到文档开头 + // An empty document (0 lines) only ever addresses the document start. #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) } @Test func clampsToTrailingEmptyLineAfterFinalNewline() { - // “a\n”在编辑器里存在可定位的第 2 行(末尾空行) + // "a\n" has an addressable second line (the trailing empty line). #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) } @Test func clampsColumnUsingUTF16LengthOfEmojiLine() { - // emoji 占 2 个 UTF-16 单元,列收敛按 UTF-16 长度而非字符数 + // An emoji spans two UTF-16 units, so convergence counts UTF-16 + // length rather than character count. let content = "a\u{1F600}b" #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) From 9a7434fa05189792ccfb7ef46073164890463919 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 13:47:01 +0800 Subject: [PATCH 5/6] fix(macos): follow theme preference in go-to-line dialog appearance The dialog panel fell back to the system appearance and rendered light inside a dark-themed editor. Apply the same AppThemePreference window appearance the workbench windows use, so the dialog matches the editor theme in system, light, and dark modes. Widen the private AppThemePreference.windowAppearance helper for reuse. --- macos/Sources/Lithe/LitheApp.swift | 5 ++++- macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 8eb2d1f41..d37d0710a 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -616,7 +616,10 @@ private func settingsWindowTitle(for language: AppLanguage) -> String { ) } -private extension AppThemePreference { +extension AppThemePreference { + /// NSAppearance applied to app windows for the selected theme; `nil` + /// means follow the system appearance. Shared by every presenting + /// window, including the Go to Line dialog. var windowAppearance: NSAppearance? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift index d6f2f16f4..f1f8f6790 100644 --- a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -26,7 +26,10 @@ enum GoToLineDialog { let coordinator = DialogCoordinator() coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } - let panel = makePanel(coordinator: coordinator) + let panel = makePanel( + coordinator: coordinator, + appearance: model.settings.themePreference.windowAppearance + ) configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) center(panel: panel) panel.makeKeyAndOrderFront(nil) @@ -49,7 +52,7 @@ enum GoToLineDialog { return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" } - private static func makePanel(coordinator: DialogCoordinator) -> NSPanel { + private static func makePanel(coordinator: DialogCoordinator, appearance: NSAppearance?) -> NSPanel { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), styleMask: [.titled, .closable], @@ -60,6 +63,10 @@ enum GoToLineDialog { panel.isReleasedWhenClosed = false panel.level = .floating panel.delegate = coordinator + // Follow the same theme preference as the workbench windows; without + // this the panel falls back to the system appearance and renders + // light inside a dark-themed editor. + panel.appearance = appearance return panel } From c0e32053374547ca948597ab62058aa0a6b35951 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 14:24:36 +0800 Subject: [PATCH 6/6] fix(macos): register go-to-line in zh-Hans resources and update catalog count The Simplified Chinese localization test requires every command catalog entry to carry translated title and subtitle strings, and the keyboard shortcut test pins the catalog size. Cover the new go-to-line command and bump the expected count to 33. --- macos/Resources/zh-Hans.lproj/Localizable.strings | 3 +++ macos/Tests/LitheTests/KeyboardShortcutTests.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 282793945..e6df499b8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -250,6 +250,7 @@ "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; "Replace in File…" = "在文件中替换…"; +"Go to Line…" = "跳转到行…"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -904,6 +905,8 @@ "Search within the active editor" = "在当前编辑器中搜索"; "Replace in File" = "在文件中替换"; "Replace within the active editor" = "在当前编辑器中替换"; +"Go to Line" = "跳转到行"; +"Jump to a line and column in the active editor" = "在当前编辑器中跳转到指定的行和列"; "Navigate to a call site of the selected Java symbol" = "导航到所选 Java 符号的调用位置"; "Find references to the selected Java symbol" = "查找所选 Java 符号的引用"; "Open history for the active file" = "打开当前文件的历史记录"; diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 5f03ede0a..26dc2506a 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 32) + #expect(commands.count == 33) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in