Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/superpowers/specs/2026-08-29-editor-find-replace-design.md
Original file line number Diff line number Diff line change
@@ -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. 上述验证脚本全部通过。
102 changes: 102 additions & 0 deletions docs/superpowers/specs/2026-08-30-editor-find-replace-tech-plan.md
Original file line number Diff line number Diff line change
@@ -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
```
6 changes: 6 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@
"Navigate" = "导航";
"Search Everywhere…" = "全局搜索…";
"Find in File…" = "在文件中查找…";
"Replace in File…" = "在文件中替换…";
"Go to Line…" = "跳转到行…";
"Find Next" = "查找下一个";
"Find Previous" = "查找上一个";
"Go to Usage" = "跳转到调用位置";
Expand Down Expand Up @@ -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" = "打开当前文件的历史记录";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,26 @@ 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,
line: Int,
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)
self.utf16Column = max(0, utf16Column)
self.isReadOnly = isReadOnly
self.displayPath = displayPath
self.virtualProviderID = virtualProviderID
self.selectsWholeLine = selectsWholeLine
}
}

Expand Down
17 changes: 16 additions & 1 deletion macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading