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..b4e8a2e96 --- /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` 语义展开(`$n` 数字捕获组,如 `$1`);`${name}` 命名分组模板当前 SDK 不支持,按原样返回;字面量模式下替换文本原样使用。 + +### 入口与快捷键 + +- 查找栏内提供选项菜单和替换行开关。 +- 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..2a88f8d90 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md @@ -0,0 +1,102 @@ +# 编辑器内查找替换技术方案 + +依据 `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` 一致。 + +## 编辑器替换管线 + +- 替换通知携带 `activeDocument.id`(`FindNotificationKeys.documentID`);`CodeTextView` 记录自身绑定的 + `documentID`,收到 `litheFindReplaceNext` / `litheFindReplaceAll` 时先校验目标文档,分栏下非当前 + 编辑器直接忽略,避免误伤其他文件。 +- 替换下一处:`insertText(_:replacementRange:)` 进入标准输入管线(撤销、`shouldChangeText` 委托、装饰刷新一致); + 完成后选中替换区之后的第一个匹配,跳过替换文本自身新产生的匹配;没有更靠后的匹配时从文档开头回绕, + 仍跳过与替换区重叠的匹配。 +- 替换全部:先基于当前匹配列表按模板展开重建全文,再 `shouldChangeText` + `NSTextStorage.replaceCharacters` + + `didChangeText` 一步提交,形成单个撤销步骤;随后整篇重算匹配并校正选区。 +- 匹配高亮、n/m 计数经由既有 `reportFindState` → `scheduleFindStateUpdate` 通路刷新。 +- 只读文档:文本视图 `isEditable == false`,两个替换入口先检查 `isEditable`,`shouldChangeText` 返回 false,替换为无操作。 +- 诊断、Git 行标记、Local History、自动保存由 `textDidChange` 既有管线自然触发,与手工编辑等价。 + +## 按行增量重算窗口 + +`applyFindEdit` 保留按行增量优化,重算窗口在编辑所在行基础上向两侧各扩一个字符(夹取到文档边界), +并移除所有与窗口相交的旧匹配后重新枚举。扩一个字符的原因:行首/行尾匹配的全词边界落在相邻行, +编辑相邻行的首尾字符会改变其合法性,只有窗口覆盖到该字符才能移除并重算。 + +例外:正则模式可能产生跨行匹配,行窗口增量无法覆盖(匹配起点在窗口之外时找不回来), +因此正则模式下 `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..e6df499b8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -249,6 +249,8 @@ "Navigate" = "导航"; "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; +"Replace in File…" = "在文件中替换…"; +"Go to Line…" = "跳转到行…"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -901,6 +903,10 @@ "Search text across the workspace" = "搜索整个工作区的文本"; "Find in File" = "在文件中查找"; "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/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1196dc253..1007e28fc 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? + /// 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( 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 fbf7b1914..d37d0710a 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) } @@ -346,6 +352,12 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) + + Button("Go to Line…") { + model.showGoToLine() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) + .disabled(model.activeDocument == nil) } Divider() @@ -604,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/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 2b0a9c53b..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", "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+FindInFile.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift new file mode 100644 index 000000000..f81def968 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift @@ -0,0 +1,123 @@ +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() { + guard let documentID = activeDocument?.id else { return } + // 携带目标文档标识:分栏时只有绑定同一文档的编辑器执行替换 + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + 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.documentID: documentID, + 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+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift new file mode 100644 index 000000000..f8075c705 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -0,0 +1,38 @@ +import Foundation + +/// 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 showGoToLine() { + guard activeDocument != nil else { return } + if isFindBarVisible { + hideFindBar() + } + editorChrome.setGoToLineVisible(true) + } + + func hideGoToLine() { + editorChrome.setGoToLineVisible(false) + } + + /// 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 } + 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 21b5f6c86..36ddd67c0 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 } @@ -1063,6 +1053,7 @@ final class AppModel: ObservableObject, Identifiable { standaloneFileURL = nil documentFeature.reset() editorChrome.resetFindBar() + editorChrome.setGoToLineVisible(false) didCloseProject?() } @@ -1407,45 +1398,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..16157e9ec 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -83,12 +83,20 @@ 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" + /// 替换通知的目标文档标识;接收编辑器必须与之匹配才执行替换。 + static let documentID = "documentID" } 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..2e81a82a0 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -9,7 +9,11 @@ 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 + @Published private(set) var findReplaceText = "" private(set) var findMatchCount = 0 private(set) var currentFindMatchIndex = 0 @@ -26,6 +30,19 @@ 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) + } + } + + func setGoToLineVisible(_ isVisible: Bool) { + guard isGoToLineVisible != isVisible else { return } + isGoToLineVisible = isVisible + if isVisible, isFindBarVisible { + setFindBarVisible(false) + } } func setFindBarQuery(_ query: String) { @@ -33,6 +50,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 +72,11 @@ final class EditorChromeModel: ObservableObject { findMatchCount = count } + /// 查找选项与替换文本在当前工作区会话内保留,关闭查找栏不重置。 func resetFindBar() { setFindBarVisible(false) setFindBarQuery("") + setReplaceVisible(false) updateFindState(currentIndex: 0, count: 0) } @@ -50,5 +84,6 @@ final class EditorChromeModel: ObservableObject { update(caret: nil) update(selectedText: "") resetFindBar() + setGoToLineVisible(false) } } diff --git a/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift new file mode 100644 index 000000000..2e11a3491 --- /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 语义展开 $n 数字捕获组 + /// (${name} 命名分组模板当前 SDK 不支持,原样返回),字面量模式原样返回。匹配列表与当前文本不一致时按字面量处理。 + 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 数字分组展开不受影响 + 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/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift new file mode 100644 index 000000000..0386bbec6 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -0,0 +1,45 @@ +import Foundation + +/// 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 + + /// 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 } + 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) + } + + /// 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) + 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..a8f4d624e 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,6 +12,9 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int + /// Select the whole target line on arrival (Go to Line); symbol and find + /// navigation keep a zero-length caret. + 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 66ab4fc2c..a19e9868e 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -37,6 +37,8 @@ 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-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 f9b19f6b8..7949535c9 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -80,6 +80,8 @@ 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-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 197534cca..857ed01c3 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?.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 @@ -539,13 +540,17 @@ 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 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 +578,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 +1112,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() } @@ -1322,7 +1328,17 @@ 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 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 + } + 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() @@ -1484,6 +1500,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)? @@ -1498,6 +1515,9 @@ 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) + /// 本视图绑定的文档标识;替换通知只在与之匹配时生效,防止分栏误伤。 + var documentID: UUID? private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -1699,6 +1719,16 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { clearFindHighlights() return } + let matcher = query == findMatcher.query + ? 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) @@ -1709,34 +1739,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 +1903,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 +1940,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 +2034,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() } @@ -2790,6 +2874,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) @@ -2820,6 +2912,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) @@ -3015,6 +3111,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 +3138,33 @@ 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) { + 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) { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 67fdcbcd0..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 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/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift new file mode 100644 index 000000000..f1f8f6790 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -0,0 +1,223 @@ +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, + appearance: model.settings.themePreference.windowAppearance + ) + 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, appearance: NSAppearance?) -> 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 + // 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 + } + + 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 1b0e80976..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( diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 1dc9a7662..7c35c8537 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,11 +1,22 @@ import SwiftUI +/// 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 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..10eb0703d 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.showGoToLine() + } 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.showGoToLine() + } MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 797e17d9b..3e5b039f0 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -72,4 +72,79 @@ 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") + } + + @Test + 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) + #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/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/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift new file mode 100644 index 000000000..9ea69ca87 --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import Lithe + +struct GoToLineInputTests { + @Test + func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { + // "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)) + } + + @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() { + // 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) + #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() { + // 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)) + } + + @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() { + // 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" 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() { + // 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)) + #expect(GoToLineInput.clamped(line: 0, column: 5, in: content) == GoToLineInput(line: 0, column: 4)) + } +} diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 1ccb9a4d6..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 == 31) + #expect(commands.count == 33) #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..438a8a173 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,13 +2336,77 @@ 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"]) } + @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)