Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ Eclipse 文件继承的 macOS 映射见 [`Mac OS X 10.5+.xml`](https://github.co
旧命令。

Search Everywhere 的 macOS 预设保留双击 Shift 和 `⇧⌘O` 两个默认入口。
Find Action 使用稳定 ID `find-action`,macOS 默认 `⇧⌘A`,由搜索模块持有。
它调用现有 Search Everywhere 的 `/` 命令模式;重复调用会替换当前查询并重新
聚焦,不关闭弹窗。菜单和监听器继续使用当前有效绑定,录制时仍暂停分发。
当前工作树没有可核验的上游 keymap XML,因此 IDEA Classic 和 Eclipse
显式不绑定此命令,避免误继承 macOS 默认值。关闭弹窗清空命令模式的临时查询,普通搜索仍保留草稿;无工作区
或搜索模块不可用时禁用,激活失败走现有关闭流程。不新增键盘事件监听器或资源写入。
原生弹窗获得焦点时会全选恢复的草稿;每次打开或重新填入查询后,搜索输入框通过
AppKit 重新获得焦点,若内容恰为 `/` 则把插入点放到 `/` 后,避免输入 `run` 时替换
斜线而误回工作区搜索。普通草稿仍沿用原生全选行为。

该功能只属于 macOS,不把 Windows keymap 或新的跨平台
快捷键契约纳入本决策。

Expand Down Expand Up @@ -94,8 +104,15 @@ Search Everywhere 的 macOS 预设保留双击 Shift 和 `⇧⌘O` 两个默认
- `./scripts/architecture/verify-service-boundaries.sh`
- `macos/Tests/LitheTests/KeyboardShortcutTests.swift`
- `macos/Tests/LitheTests/AppLocalizationTests.swift`
- `macos/Tests/LitheTests/SearchEverywhereQueryModeTests.swift`
- `./scripts/agent-notes/verify-agent-notes.sh`

原生验证步骤:从编辑器、侧栏及第二个项目窗口按 `⇧⌘A`,确认输入聚焦,
输入 `/run` 过滤操作,Return 只执行一次,Escape 关闭,重开无旧文本;弹窗
已打开时再次调用应切换到 `/` 并聚焦。检查改绑、禁用、恢复、录制抑制,以及
双击 Shift 和 `⇧⌘O` 的普通工作区搜索。原生交互验收仍待执行,不能用静态
检查代替运行时证据。

测试覆盖命令目录唯一性和默认冲突、绑定编码往返、用户覆盖持久化、禁用和
恢复、损坏载荷回退、过滤、冲突拒绝、监听器更新以及录制期间暂停分发。

Expand Down
6 changes: 6 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1495,10 +1495,13 @@
"Filter results" = "Filter results";
"Filter route, controller, or method" = "Filter route, controller, or method";
"Find" = "Find";
"Find Action" = "Find Action";
"Find Action…" = "Find Action…";
"Find Branch" = "Find Branch";
"Find Next" = "Find Next";
"Find Previous" = "Find Previous";
"Find Usages" = "Find Usages";
"Find and execute a command" = "Find and execute a command";
"Find files and actions" = "Find files and actions";
"Find in File" = "Find in File";
"Find in Files" = "Find in Files";
Expand Down Expand Up @@ -5997,6 +6000,9 @@
"native.filterResults" = "Filter results";
"native.filterRouteControllerOrMethod" = "Filter route, controller, or method";
"native.find" = "Find";
"native.findAction" = "Find Action";
"native.findAction.variant2" = "Find Action…";
"native.findAndExecuteCommand" = "Find and execute a command";
"native.findBranch" = "Find Branch";
"native.findFilesAndActions" = "Find files and actions";
"native.findInFile" = "Find in File";
Expand Down
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 @@ -1495,10 +1495,13 @@
"Filter results" = "筛选结果";
"Filter route, controller, or method" = "筛选路由、控制器或方法";
"Find" = "查找";
"Find Action" = "查找操作";
"Find Action…" = "查找操作…";
"Find Branch" = "查找分支";
"Find Next" = "查找下一个";
"Find Previous" = "查找上一个";
"Find Usages" = "查找调用位置";
"Find and execute a command" = "查找并执行命令";
"Find files and actions" = "查找文件和操作";
"Find in File" = "在文件中查找";
"Find in Files" = "在项目中查找";
Expand Down Expand Up @@ -5997,6 +6000,9 @@
"native.filterResults" = "筛选结果";
"native.filterRouteControllerOrMethod" = "筛选路由、控制器或方法";
"native.find" = "查找";
"native.findAction" = "查找操作";
"native.findAction.variant2" = "查找操作…";
"native.findAndExecuteCommand" = "查找并执行命令";
"native.findBranch" = "查找分支";
"native.findFilesAndActions" = "查找文件和操作";
"native.findInFile" = "在文件中查找";
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,12 @@ struct LitheApp: App {

Divider()

Button("Find Action…") {
model.openFindAction()
}
.litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-action"))
.disabled(!model.canPerformShortcutCommand(id: "find-action"))

Button("Search Everywhere…") {
model.toggleSearchEverywhere()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ extension AppModel {
supportsLanguageServerFeature(.implementation)
case "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out":
genericDebugFeatureIfActive?.state == .paused
case "close-project", "search-everywhere", "search-in-project",
case "close-project", "search-everywhere", "find-action", "search-in-project",
"replace-in-project", "project-local-history", "run", "debug",
"stop-run", "stop-debug", "toggle-terminal", "toggle-problems",
"toggle-git-log", "toggle-run", "toggle-tests",
Expand Down
20 changes: 17 additions & 3 deletions macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ extension AppModel {

func toggleSearchEverywhere() {
guard workspaceURL != nil, !isSearchEverywhereVisible else { return }
openSearchEverywhere()
}

func openFindAction() {
guard canPerformShortcutCommand(id: "find-action") else { return }
openSearchEverywhere(query: "/")
}

private func openSearchEverywhere(query: String? = nil) {
let wasVisible = isSearchEverywhereVisible
let root = workspaceURL
searchSessionFeature.everywhereGitStatus = gitTreeStatusProjection
searchSessionFeature.beginSearchEverywhere()
searchSessionFeature.beginSearchEverywhere(query: query)
guard !wasVisible else { return }
Task { [weak self] in
guard let self else { return }
guard let self, workspaceURL == root, isSearchEverywhereVisible else { return }
if await activateSearchModule() == nil {
isSearchEverywhereVisible = false
if workspaceURL == root { dismissSearchEverywhere() }
return
}
guard workspaceURL == root, isSearchEverywhereVisible else { return }
Expand All @@ -36,6 +47,9 @@ extension AppModel {
func dismissSearchEverywhere() {
guard isSearchEverywhereVisible else { return }
searchSessionFeature.endSearchEverywhere()
if searchSessionFeature.everywhereQuery.hasPrefix("/") {
searchSessionFeature.everywhereQuery = ""
}
searchFeatureIfActive?.clearSearchEverywhere()
documentFeature.discardPreviewDocuments(owner: .searchEverywhere)
}
Expand Down
2 changes: 2 additions & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,8 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling {
springFeature.reset()
mybatisFeature.reset()
workspaceSessionCoordinator.resetWorkspaceFeature()
// The popup belongs to the old project; an activation it started may never finish.
dismissSearchEverywhere()
searchModuleCoordinator.resetFeature(searchFeatureIfActive)
workbenchFeature.hideAllToolWindows()
editorChrome.reset()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ enum KeyboardShortcutPreset: String, CaseIterable, Sendable {
"toggle-breakpoint": shortcut("f8", [.control]),
"view-breakpoints": shortcut("f8", [.control, .shift]),
"search-everywhere": [.doubleTap(.shift)],
"find-action": [],
"navigate-back": shortcut("left", [.control, .option]),
"navigate-forward": shortcut("right", [.control, .option]),
"find-in-file": shortcut("f", [.control]),
Expand Down Expand Up @@ -80,6 +81,7 @@ enum KeyboardShortcutPreset: String, CaseIterable, Sendable {
"toggle-breakpoint": shortcut("f8", [.control]),
"view-breakpoints": shortcut("f8", [.control, .shift]),
"search-everywhere": [.doubleTap(.shift)],
"find-action": [],
"navigate-back": shortcut("[", [.command]),
"navigate-forward": shortcut("]", [.command]),
"find-in-file": shortcut("f", [.command]),
Expand Down Expand Up @@ -156,6 +158,7 @@ enum LitheCommandCatalog {
.keyPress(key: "o", modifiers: [.shift, .command])
]
),
command("find-action", "Find Action", "Find and execute a command", .navigation, "a", [.shift, .command]),
command("navigate-back", "Back", "Navigate to the previous editor location", .navigation, "[", [.command]),
command("navigate-forward", "Forward", "Navigate to the next editor location", .navigation, "]", [.command]),
command("find-in-file", "Find in File", "Search within the active editor", .navigation, "f", [.command]),
Expand Down Expand Up @@ -192,7 +195,7 @@ enum LitheCommandCatalog {
"toggle-breakpoint", "view-breakpoints", "toggle-debug": .debug
case "toggle-terminal": .terminal
case "toggle-git-log": .git
case "search-everywhere", "search-in-project", "replace-in-project": .search
case "search-everywhere", "find-action", "search-in-project", "replace-in-project": .search
case "toggle-problems", "spring-endpoints": .languageIntelligence
case "local-history", "project-local-history": .localHistory
default: nil
Expand Down
1 change: 1 addition & 0 deletions macos/Sources/Lithe/Models/LitheAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ enum LitheActionRegistry {
action("rebuild-java-index", model: model) { model.rebuildJavaIndex() },
action("save", model: model) { model.saveActiveDocument() },
action("search-everywhere", model: model) { model.toggleSearchEverywhere() },
action("find-action", model: model) { model.openFindAction() },
action("navigate-back", model: model) { model.navigateBack() },
action("navigate-forward", model: model) { model.navigateForward() },
action("find-next", model: model) { model.navigateFind(offset: 1) },
Expand Down
69 changes: 69 additions & 0 deletions macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ private struct SearchEverywhereContent: View {
let availableSize: CGSize
@Environment(\.locale) private var locale
@State private var searchFocused = false
@State private var inputFocusRequest = 0
@State private var draftQuery: String?
private var query: String {
get { draftQuery ?? session.everywhereQuery }
Expand Down Expand Up @@ -253,6 +254,13 @@ private struct SearchEverywhereContent: View {
installKeyMonitor()
}
.onDisappear { removeKeyMonitor() }
// beginSearchEverywhere publishes even when the popup is already visible.
// Read the seeded query, then ask AppKit to focus the existing input again.
.onReceive(session.$isSearchEverywhereVisible) { visible in
guard visible else { return }
draftQuery = session.everywhereQuery
inputFocusRequest &+= 1
}
.onChange(of: query) { _ in
selectedIndex = 0
selectionRevealRevision &+= 1
Expand Down Expand Up @@ -336,6 +344,7 @@ private struct SearchEverywhereContent: View {
.foregroundStyle(LitheTheme.searchFieldPlaceholder)
.allowsHitTesting(false)
}
.background(SearchEverywhereInputFocus(request: inputFocusRequest))
.accessibilityLabel("Search Everywhere")
.background(LitheToolWindowActivityTracker(isActive: $searchFocused))
.padding(.horizontal, 5)
Expand Down Expand Up @@ -904,3 +913,63 @@ struct SearchEverywhereSourcePreview: View {
releaseDocument(request.url, request.id)
}
}

/// Focuses the Search Everywhere input each time the popup is shown or reseeded.
/// The popup panel selects restored text when it takes focus; for the seeded
/// command prefix the caret goes after "/" so typing filters commands.
private struct SearchEverywhereInputFocus: NSViewRepresentable {
let request: Int

func makeNSView(context: Context) -> SearchEverywhereInputFocusView { SearchEverywhereInputFocusView() }

func updateNSView(_ view: SearchEverywhereInputFocusView, context: Context) {
view.request(request)
}
}

final class SearchEverywhereInputFocusView: NSView {
private var pending = 0
private var handled = 0

override func hitTest(_ point: NSPoint) -> NSView? { nil }

func request(_ value: Int) {
pending = value
schedule()
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
schedule()
}

private func schedule() {
guard pending != handled, window != nil else { return }
// Run after the popup finishes presenting, so its own focus and selection come first.
DispatchQueue.main.async { [weak self] in self?.apply() }
}

private func apply() {
guard pending != handled, let window,
let field = Self.textField(in: window.contentView, within: convert(bounds, to: nil))
else { return }
handled = pending
if field.currentEditor() == nil || window.firstResponder !== field.currentEditor() {
window.makeFirstResponder(field)
}
if field.stringValue == "/", let editor = field.currentEditor() {
editor.selectedRange = NSRange(location: 1, length: 0)
}
}

/// The search row also holds an icon and trailing accessories, so match the
/// editable field that lies inside this helper's frame rather than its center.
private static func textField(in view: NSView?, within rect: NSRect) -> NSTextField? {
guard let view else { return nil }
if let field = view as? NSTextField, field.isEditable,
rect.contains(field.convert(NSPoint(x: field.bounds.midX, y: field.bounds.midY), to: nil)) {
return field
}
return view.subviews.lazy.compactMap { textField(in: $0, within: rect) }.first
}
}
9 changes: 9 additions & 0 deletions macos/Tests/LitheTests/AppLocalizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,15 @@ struct AppLocalizationTests {
func simplifiedChineseResourcesCoverKeymapControls() throws {
let translations = try simplifiedChineseTranslations()

let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
.deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources")
let english = try #require(Bundle(url: resources.appendingPathComponent("en.lproj")))
for key in ["Find Action", "Find Action…", "Find and execute a command"] {
#expect(english.localizedString(forKey: key, value: "MISSING", table: nil) == key)
}
#expect(translations["Find Action"] == "查找操作")
#expect(translations["Find Action…"] == "查找操作…")
#expect(translations["Find and execute a command"] == "查找并执行命令")
#expect(translations["Keymap"] == "快捷键")
#expect(translations["Search actions or shortcuts"] == "搜索操作或快捷键")
#expect(translations["Restore All Defaults"] == "全部恢复默认")
Expand Down
33 changes: 23 additions & 10 deletions macos/Tests/LitheTests/KeyboardShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import Testing
@MainActor
struct KeyboardShortcutTests {
@Test
func catalogHasStableUniqueCommandsAndConflictFreeDefaults() {
func catalogHasStableUniqueCommandsAndConflictFreeDefaults() throws {
let commands = LitheCommandCatalog.commands
#expect(commands.count == 38)
#expect(commands.count == 39)
let findAction = try #require(LitheCommandCatalog.command(id: "find-action"))
#expect(findAction.defaultBindings
== [.keyPress(key: "a", modifiers: [.shift, .command])])
for preset in [KeyboardShortcutPreset.ideaClassic, .eclipse] {
#expect(preset.bindings(for: findAction).isEmpty)
}
// Build plugin windows own their actions; Maven is no longer a built-in command.
#expect(LitheCommandCatalog.command(id: "toggle-maven") == nil)
#expect(Set(commands.map(\.id)).count == commands.count)
Expand Down Expand Up @@ -103,6 +109,7 @@ struct KeyboardShortcutTests {
#expect(actionIDs == commandIDs)
#expect(actionIDs.contains("save"))
#expect(actionIDs.contains("search-everywhere"))
#expect(actionIDs.contains("find-action"))
#expect(actionIDs.contains("find-next"))
#expect(actionIDs.contains("find-previous"))
#expect(actionIDs.contains("navigate-back"))
Expand Down Expand Up @@ -145,17 +152,23 @@ struct KeyboardShortcutTests {
modifiers: [.command, .option]
)

try feature.replaceBindings(for: "run", with: [replacement])
#expect(feature.effectiveBindings(for: "run") == [replacement])
#expect(AppSettings(store: store).keyboardShortcutOverrides["run"] == [replacement])
try feature.replaceBindings(for: "find-action", with: [replacement])
#expect(feature.effectiveBindings(for: "find-action") == [replacement])
#expect(feature.primaryKeyPress(for: "find-action") == replacement)
#expect(feature.displayText(for: "find-action") == replacement.displayText)
#expect(feature.registrations.first { $0.commandID == "find-action" }?.bindings == [replacement])
#expect(AppSettings(store: store).keyboardShortcutOverrides["find-action"] == [replacement])

try feature.replaceBindings(for: "run", with: [])
#expect(feature.effectiveBindings(for: "run").isEmpty)
try feature.replaceBindings(for: "find-action", with: [])
#expect(feature.effectiveBindings(for: "find-action").isEmpty)
#expect(feature.primaryKeyPress(for: "find-action") == nil)
#expect(feature.displayText(for: "find-action") == nil)
#expect(feature.registrations.first { $0.commandID == "find-action" }?.bindings == [])

feature.resetCommand("run")
feature.resetCommand("find-action")
#expect(
feature.effectiveBindings(for: "run")
== LitheCommandCatalog.command(id: "run")?.defaultBindings
feature.effectiveBindings(for: "find-action")
== LitheCommandCatalog.command(id: "find-action")?.defaultBindings
)

settings.editorFontSize = 17
Expand Down
Loading
Loading