diff --git a/Examples/SampleSidebarExtensionApp/SampleSidebarExtensionApp.xcodeproj/xcshareddata/xcschemes/CMUXExtKitSampleSidebarApp.xcscheme b/Examples/SampleSidebarExtensionApp/SampleSidebarExtensionApp.xcodeproj/xcshareddata/xcschemes/CMUXExtKitSampleSidebarApp.xcscheme index e02f06e29804..de918a81eb82 100644 --- a/Examples/SampleSidebarExtensionApp/SampleSidebarExtensionApp.xcodeproj/xcshareddata/xcschemes/CMUXExtKitSampleSidebarApp.xcscheme +++ b/Examples/SampleSidebarExtensionApp/SampleSidebarExtensionApp.xcodeproj/xcshareddata/xcschemes/CMUXExtKitSampleSidebarApp.xcscheme @@ -1,7 +1,7 @@ + version = "1.8"> + version = "1.8"> String { + let trimmed = matchingQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") || trimmed.hasPrefix("/") || trimmed.hasPrefix("./") { + if let lastSlash = trimmed.lastIndex(of: "/") { + let term = String(trimmed[trimmed.index(after: lastSlash)...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.hasSuffix("/") { + let expanded: String + if trimmed.hasPrefix("~") { + expanded = NSHomeDirectory() + String(trimmed.dropFirst()) + } else if trimmed.hasPrefix("./") { + let relative = String(trimmed.dropFirst(2)) + let trimmedRoot = workspaceRoot?.trimmingCharacters(in: .whitespacesAndNewlines) + let base: String + if let trimmedRoot, !trimmedRoot.isEmpty { + base = trimmedRoot + } else { + base = FileManager.default.currentDirectoryPath + } + expanded = relative.isEmpty ? base : base + "/" + relative + } else { + expanded = trimmed + } + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir), + isDir.boolValue { + return "" + } + } + return term + } + return "" + } + return trimmed + } + + /// Returns a stable process-local fingerprint for a query. + public static func fingerprint(query: String) -> Int { + var hasher = Hasher() + hasher.combine(query) + return hasher.finalize() + } + + /// Returns the deduplication fingerprint for cross-directory file search. + public static func crossDirectoryDedupFingerprint( + query: String, + workspaceRoot: String? = nil + ) -> Int { + var hasher = Hasher() + hasher.combine("cross-directory") + hasher.combine(query) + hasher.combine(workspaceRoot ?? "") + return hasher.finalize() + } + + /// Resolves a matching query into the directory to list and search mode. + public static func resolve( + matchingQuery: String, + workspaceRoot: String? + ) -> ResolvedPath { + guard let root = workspaceRoot, !root.isEmpty else { + return (NSHomeDirectory(), "", true) + } + + let trimmed = matchingQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return (root, "", true) + } + + if trimmed.hasPrefix("~") || trimmed.hasPrefix("/") { + let expanded: String + if trimmed.hasPrefix("~") { + expanded = NSHomeDirectory() + String(trimmed.dropFirst()) + } else { + expanded = trimmed + } + let (existingDir, remainder) = resolveLongestExistingDirectory(expanded) + return (existingDir ?? root, remainder, true) + } + + if trimmed.hasPrefix("./") { + let relative = String(trimmed.dropFirst(2)) + let expanded = relative.isEmpty ? root : root + "/" + relative + let (existingDir, remainder) = resolveLongestExistingDirectory(expanded) + return (existingDir ?? root, remainder, true) + } + + return (root, trimmed, false) + } + + /// Resolves the deepest existing directory prefix and unmatched remainder. + public static func resolveLongestExistingDirectory(_ path: String) -> (existingDir: String?, remainder: String) { + let components = path.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + let fileManager = FileManager.default + var bestDir: String? + var bestEnd = 0 + for end in stride(from: components.count, through: 1, by: -1) { + let candidate = components[0.. String { + let homeDir = NSHomeDirectory() + let path = url.path + if path.hasPrefix(rootDir + "/") { + let relative = String(path.dropFirst(rootDir.count + 1)) + return usePathPrefix ? "./" + relative + "/" : relative + "/" + } + if path == rootDir { + return usePathPrefix ? "./" : "" + } + if path.hasPrefix(homeDir + "/") { + return "~" + String(path.dropFirst(homeDir.count)) + "/" + } + if path == homeDir { + return "~/" + } + return path + "/" + } + + /// Classifies how Quick Open should open a file URL. + public static func openAction(for url: URL) -> CommandPaletteQuickOpenFileOpenAction { + let resolvedURL = url.resolvingSymlinksInPath() + let utType = UTType(filenameExtension: resolvedURL.pathExtension) + let resourceValues = try? resolvedURL.resourceValues( + forKeys: [.isExecutableKey, .isSymbolicLinkKey] + ) + let isExecutable = resourceValues?.isExecutable ?? false + + let isScript: Bool = { + guard let utType else { return false } + return utType.conforms(to: .shellScript) + || utType.conforms(to: .pythonScript) + || utType.conforms(to: .rubyScript) + || utType.conforms(to: .perlScript) + || utType.conforms(to: .phpScript) + || utType.conforms(to: .script) + }() + + let isKnownBinary: Bool = { + guard let utType else { return false } + return utType.conforms(to: .executable) || utType.conforms(to: .unixExecutable) + }() + + if isScript { + return .textEditor(resolvedURL) + } + + if isKnownBinary { + return .reveal(resolvedURL) + } + + if isExecutable && utType == nil { + return isTextFile(at: resolvedURL) ? .textEditor(resolvedURL) : .reveal(resolvedURL) + } + + if utType == nil, isTextFile(at: resolvedURL) { + return .textEditor(resolvedURL) + } + + return .open(resolvedURL) + } + + /// Returns whether a URL currently resolves to a directory. + public static func isDirectory(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + + /// Lists files in a directory, sorted with directories first. + public static func listFiles(inDirectory dir: String, maxCount: Int) -> [URL] { + let fileManager = FileManager.default + guard let contents = try? fileManager.contentsOfDirectory( + at: URL(fileURLWithPath: dir, isDirectory: true), + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + ) else { + return [] + } + let sorted = contents.sorted { a, b in + let aIsDir = isDirectory(a) + let bIsDir = isDirectory(b) + if aIsDir != bIsDir { return aIsDir } + return a.lastPathComponent.localizedCaseInsensitiveCompare(b.lastPathComponent) == .orderedAscending + } + return Array(sorted.prefix(maxCount)) + } + + /// Returns whether a directory should be skipped during recursive Quick Open search. + public static func shouldSkipDirectory(_ name: String) -> Bool { + switch name { + case "node_modules", ".build", "DerivedData", + ".svn", ".hg", "__pycache__", ".cache", + "Pods", "Carthage", ".tox", ".eggs", + "build", "dist", ".next", ".nuxt", + "target", ".dart_tool", ".idea", + ".vscode", ".vs", ".swiftpm", + "vendor", "bower_components": + return true + default: + return false + } + } + + /// Searches across a workspace tree and returns the top scored file matches. + public static func searchCrossDirectory( + query: String, + rootDir: String + ) async -> [CommandPaletteQuickOpenScoredFile] { + guard !query.isEmpty else { return [] } + let fileManager = FileManager.default + let dirKeys: [URLResourceKey] = [.isDirectoryKey] + let rootURL = URL(fileURLWithPath: rootDir, isDirectory: true) + let ideal = fuzzyScore(query: query, candidate: query) + let threshold = ideal.map { Int(Double($0) * fastQuitRatio) } + + guard let rootContents = try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: dirKeys, + options: [.skipsHiddenFiles] + ) else { return [] } + + var topDirs: [URL] = [] + var seenTopDirectoryPaths: Set = [] + var globalHeap: [(score: Int, url: URL, depth: Int)] = [] + for url in rootContents { + if Task.isCancelled { return [] } + let isDir = isDirectory(url) + if isDir, !shouldSkipDirectory(url.lastPathComponent) { + let canonicalPath = url.resolvingSymlinksInPath().path + if seenTopDirectoryPaths.insert(canonicalPath).inserted { + topDirs.append(url) + } + } + let title = searchCandidatePath(url: url, rootDir: rootDir, isDirectory: isDir) + if let score = fuzzyScore(query: query, candidate: title), score > 0 { + insertTopK(&globalHeap, (score, url, 0), limit: fastQuitKeepMax) + } + } + + await withTaskGroup(of: [(score: Int, url: URL, depth: Int)].self) { group in + let maxConcurrentBranches = min(8, max(1, ProcessInfo.processInfo.activeProcessorCount)) + var nextDirectoryIndex = 0 + + func addNextBranchIfNeeded() { + guard !Task.isCancelled, nextDirectoryIndex < topDirs.count else { return } + let dirURL = topDirs[nextDirectoryIndex] + nextDirectoryIndex += 1 + group.addTask { + searchCrossDirectoryBranch( + query: query, + rootDir: rootDir, + rootDirectory: dirURL, + threshold: threshold + ) + } + } + + for _ in 0.. $1.score } + return globalHeap.map { + CommandPaletteQuickOpenScoredFile(url: $0.url, score: $0.score, depth: $0.depth) + } + } + + /// Returns the fuzzy score for a cross-directory file-search candidate. + public static func fuzzyScore(query: String, candidate: String) -> Int? { + fuzzyMatch(query: query, candidate: candidate)?.score + } + + /// Returns the fuzzy score and matched character indices for a candidate. + public static func fuzzyMatch( + query: String, + candidate: String + ) -> (score: Int, indices: Set)? { + let queryChars = Array(CommandPaletteFuzzyMatcher.normalizeForSearch(query)) + let candidateChars = Array(CommandPaletteFuzzyMatcher.normalizeForSearch(candidate)) + guard !queryChars.isEmpty else { return (0, []) } + guard queryChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + var indices: Set = [] + + for queryChar in queryChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == queryChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + indices.insert(matchedIndex) + score += 90 + if matchedIndex == 0 || candidateChars[matchedIndex - 1] == "/" { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - queryChars.count) + return (max(1, score), indices) + } + + /// Returns a lowercase path relative to the root when possible. + public static func relativePath(url: URL, rootDir: String) -> String { + let path = url.path + if path.hasPrefix(rootDir + "/") { + return String(path.dropFirst(rootDir.count + 1)).lowercased() + } + + let resolvedPath = url.resolvingSymlinksInPath().path + let resolvedRoot = URL(fileURLWithPath: rootDir, isDirectory: true).resolvingSymlinksInPath().path + if resolvedPath.hasPrefix(resolvedRoot + "/") { + return String(resolvedPath.dropFirst(resolvedRoot.count + 1)).lowercased() + } + + return url.lastPathComponent.lowercased() + } + + private static func searchCandidatePath(url: URL, rootDir: String, isDirectory: Bool) -> String { + let path = relativePath(url: url, rootDir: rootDir) + return isDirectory ? path + "/" : path + } + + private static func isTextFile(at url: URL) -> Bool { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return false + } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 4096), !data.isEmpty else { + return true + } + if data.contains(0) { + return false + } + return String(data: data, encoding: .utf8) != nil + } + + private static func searchCrossDirectoryBranch( + query: String, + rootDir: String, + rootDirectory: URL, + threshold: Int? + ) -> [(score: Int, url: URL, depth: Int)] { + let fileManager = FileManager.default + let dirKeys: [URLResourceKey] = [.isDirectoryKey] + var localHeap: [(score: Int, url: URL, depth: Int)] = [] + var scanned = 0 + var queue: [(URL, Int)] = [(rootDirectory, 1)] + var visitedDirectoryPaths: Set = [rootDirectory.resolvingSymlinksInPath().path] + var head = 0 + while head < queue.count { + if Task.isCancelled { return localHeap } + let (curDir, depth) = queue[head] + head += 1 + guard let contents = try? fileManager.contentsOfDirectory( + at: curDir, + includingPropertiesForKeys: dirKeys, + options: [.skipsHiddenFiles] + ) else { continue } + for url in contents { + if Task.isCancelled { return localHeap } + scanned += 1 + let isDir = isDirectory(url) + if isDir, shouldSkipDirectory(url.lastPathComponent) { continue } + let title = searchCandidatePath(url: url, rootDir: rootDir, isDirectory: isDir) + if let score = fuzzyScore(query: query, candidate: title), score > 0 { + insertTopK(&localHeap, (score, url, depth), limit: fastQuitKeepMax) + } + if isDir { + let canonicalPath = url.resolvingSymlinksInPath().path + if visitedDirectoryPaths.insert(canonicalPath).inserted { + queue.append((url, depth + 1)) + } + } + } + if let threshold, query.count >= fastQuitMinQueryChars { + if localHeap.count >= fastQuitKeepMax, + let worst = localHeap.first?.score, + worst >= threshold, + scanned >= fastQuitMinScan { + break + } + } + } + return localHeap + } + + private static func insertTopK( + _ heap: inout [(score: Int, url: URL, depth: Int)], + _ item: (score: Int, url: URL, depth: Int), + limit: Int + ) { + if heap.count < limit { + heap.append(item) + heap.sort { $0.score < $1.score } + } else if let worst = heap.first?.score, item.score > worst { + heap[0] = item + var idx = 0 + let count = heap.count + while true { + let left = 2 * idx + 1 + let right = 2 * idx + 2 + var smallest = idx + if left < count, heap[left].score < heap[smallest].score { smallest = left } + if right < count, heap[right].score < heap[smallest].score { smallest = right } + if smallest == idx { break } + heap.swapAt(idx, smallest) + idx = smallest + } + } + } +} + +private let fastQuitKeepMax = 30 +private let fastQuitMinQueryChars = 3 +private let fastQuitRatio = 0.618 +private let fastQuitMinScan = 10_000 diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/QuickOpen/CommandPaletteQuickOpenScoredFile.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/QuickOpen/CommandPaletteQuickOpenScoredFile.swift new file mode 100644 index 000000000000..b6f379b2c803 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/QuickOpen/CommandPaletteQuickOpenScoredFile.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// A scored Quick Open file-search candidate. +public struct CommandPaletteQuickOpenScoredFile: Sendable { + /// The matched file or directory URL. + public let url: URL + /// The fuzzy-match score. + public let score: Int + /// The recursive directory depth where the item was found. + public let depth: Int + + /// Creates a scored Quick Open file-search candidate. + public init(url: URL, score: Int, depth: Int) { + self.url = url + self.score = score + self.depth = depth + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Request/CommandPaletteRequestKind.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Request/CommandPaletteRequestKind.swift index 1d97385345af..b848a060fbd5 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Request/CommandPaletteRequestKind.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Request/CommandPaletteRequestKind.swift @@ -11,6 +11,8 @@ public enum CommandPaletteRequestKind: String, Sendable, CaseIterable { case commands /// Opens the workspace switcher palette. case switcher + /// Opens the quick-open file search palette. + case fileSearch /// Opens the rename-tab prompt. case renameTab /// Opens the rename-workspace prompt. @@ -29,6 +31,8 @@ public enum CommandPaletteRequestKind: String, Sendable, CaseIterable { return "cmux.commandPaletteRequested" case .switcher: return "cmux.commandPaletteSwitcherRequested" + case .fileSearch: + return "cmux.commandPaletteFileSearchRequested" case .renameTab: return "cmux.commandPaletteRenameTabRequested" case .renameWorkspace: @@ -45,7 +49,7 @@ public enum CommandPaletteRequestKind: String, Sendable, CaseIterable { /// change rather than a call-site edit. public var marksPending: Bool { switch self { - case .commands, .switcher, .renameTab, .renameWorkspace, .editWorkspaceDescription: + case .commands, .switcher, .fileSearch, .renameTab, .renameWorkspace, .editWorkspaceDescription: return true } } diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteQuickOpenFileSearchTests.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteQuickOpenFileSearchTests.swift new file mode 100644 index 000000000000..f17f31e3f231 --- /dev/null +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteQuickOpenFileSearchTests.swift @@ -0,0 +1,475 @@ +import XCTest + +@testable import CmuxCommandPalette + +// MARK: - Quick Open File Search + +final class CommandPaletteQuickOpenFileSearchTests: XCTestCase { + + // MARK: commandPaletteFileSearchResolve + + func testResolveEmptyQueryReturnsWorkspaceRoot() { + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "", + workspaceRoot: root + ) + XCTAssertEqual(dir, root) + XCTAssertEqual(searchTerm, "") + XCTAssertTrue(isPathMode) + } + + func testResolveWhitespaceOnlyReturnsWorkspaceRoot() { + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: " ", + workspaceRoot: root + ) + XCTAssertEqual(dir, root) + XCTAssertEqual(searchTerm, "") + XCTAssertTrue(isPathMode) + } + + func testResolveHomePrefix() { + let home = NSHomeDirectory() + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "~", + workspaceRoot: root + ) + XCTAssertEqual(dir, home) + XCTAssertEqual(searchTerm, "") + XCTAssertTrue(isPathMode) + } + + func testResolveHomeSubdirectory() { + let home = NSHomeDirectory() + let root = "/Users/test/Projects/MyApp" + let (dir, _, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "~/Develop", + workspaceRoot: root + ) + // ~/Develop may or may not exist — if it does, dir should be it; + // if not, dir falls back to the longest existing prefix (likely home). + XCTAssertTrue(dir.hasPrefix(home)) + XCTAssertTrue(isPathMode) + } + + func testResolveRootSlash() { + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "/", + workspaceRoot: root + ) + XCTAssertEqual(dir, "/") + XCTAssertEqual(searchTerm, "") + XCTAssertTrue(isPathMode) + } + + func testResolveCrossDirectoryMode() { + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "main.swift", + workspaceRoot: root + ) + XCTAssertEqual(dir, root) + XCTAssertEqual(searchTerm, "main.swift") + XCTAssertFalse(isPathMode) + } + + func testResolveCrossDirectoryModeMultipleWords() { + let root = "/Users/test/Projects/MyApp" + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "content view", + workspaceRoot: root + ) + XCTAssertEqual(dir, root) + XCTAssertEqual(searchTerm, "content view") + XCTAssertFalse(isPathMode) + } + + func testResolveNilWorkspaceRootFallsBackToHome() { + let home = NSHomeDirectory() + let (dir, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "", + workspaceRoot: nil + ) + XCTAssertEqual(dir, home) + XCTAssertEqual(searchTerm, "") + XCTAssertTrue(isPathMode) + } + + // MARK: resolveLongestExistingDirectory + + func testResolveExistingRootDirectory() { + let (dir, remainder) = CommandPaletteQuickOpenFileSearch.resolveLongestExistingDirectory("/") + XCTAssertEqual(dir, "/") + XCTAssertEqual(remainder, "") + } + + func testResolveExistingHomeDirectory() { + let home = NSHomeDirectory() + let (dir, remainder) = CommandPaletteQuickOpenFileSearch.resolveLongestExistingDirectory(home) + XCTAssertEqual(dir, home) + XCTAssertEqual(remainder, "") + } + + func testResolveExistingDirectoryWithRemainder() { + let home = NSHomeDirectory() + let (dir, remainder) = CommandPaletteQuickOpenFileSearch.resolveLongestExistingDirectory(home + "/nonexistent_subdir_xyz") + XCTAssertEqual(dir, home) + XCTAssertEqual(remainder, "nonexistent_subdir_xyz") + } + + func testResolveCompletelyNonexistentPath() { + // "/xyz_nonexistent_root" doesn't exist, but "/" does — + // so dir falls back to "/" with the rest as remainder. + let (dir, remainder) = CommandPaletteQuickOpenFileSearch.resolveLongestExistingDirectory("/xyz_nonexistent_root/foo/bar") + XCTAssertEqual(dir, "/") + XCTAssertFalse(remainder.isEmpty) + } + + // MARK: commandPaletteFileSearchPathForDirectory + + func testPathForHomeSubdirectory() { + let home = NSHomeDirectory() + let url = URL(fileURLWithPath: home + "/Develop", isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory(url, rootDir: "/tmp/workspace") + XCTAssertEqual(result, "~/Develop/") + } + + func testPathForHomeDirectoryItself() { + let home = NSHomeDirectory() + let url = URL(fileURLWithPath: home, isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory(url, rootDir: "/tmp/workspace") + XCTAssertEqual(result, "~/") + } + + func testPathUnderWorkspaceRoot() { + let root = "/Users/test/Projects/MyApp" + let url = URL(fileURLWithPath: root + "/Sources", isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory(url, rootDir: root) + XCTAssertEqual(result, "./Sources/") + } + + func testPathUnderWorkspaceRootCanOmitPathModePrefix() { + let root = "/Users/test/Projects/MyApp" + let url = URL(fileURLWithPath: root + "/Sources", isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory( + url, + rootDir: root, + usePathPrefix: false + ) + + XCTAssertEqual(result, "Sources/") + + let (_, searchTerm, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: result, + workspaceRoot: root + ) + XCTAssertEqual(searchTerm, "Sources/") + XCTAssertFalse(isPathMode) + } + + func testPathUnderWorkspaceInsideHome() { + let root = NSHomeDirectory() + "/Develop/MyProject" + let url = URL(fileURLWithPath: root + "/Sources", isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory(url, rootDir: root) + XCTAssertEqual(result, "./Sources/") + } + + func testPathOutsideWorkspaceAndHome() { + let url = URL(fileURLWithPath: "/opt/homebrew", isDirectory: true) + let result = CommandPaletteQuickOpenFileSearch.pathForDirectory(url, rootDir: "/tmp/workspace") + XCTAssertEqual(result, "/opt/homebrew/") + } + + // MARK: commandPaletteFileSearchMatchingTerm + + func testMatchingTermEmpty() { + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm(""), "") + } + + func testMatchingTermCrossDirectory() { + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("main.swift"), "main.swift") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("content view"), "content view") + } + + func testCrossDirectorySlashQueryMatchesRelativePath() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-slash-\(UUID().uuidString)", isDirectory: true) + let crypto = root.appendingPathComponent("crypto", isDirectory: true) + try FileManager.default.createDirectory(at: crypto, withIntermediateDirectories: true) + let file = crypto.appendingPathComponent("gnupg.md") + try "notes".write(to: file, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + let results = await CommandPaletteQuickOpenFileSearch.searchCrossDirectory( + query: "cry/gnu", + rootDir: root.path + ) + + let expectedPath = file.resolvingSymlinksInPath().path + XCTAssertTrue( + results.contains { $0.url.resolvingSymlinksInPath().path == expectedPath }, + "Expected cry/gnu to match crypto/gnupg.md, got \(results.map { $0.url.path })" + ) + } + + func testCrossDirectoryDirectoryQueryIncludesSelectedTopLevelDirectory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-selected-dir-\(UUID().uuidString)", isDirectory: true) + let selectedDirectory = root.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory(at: selectedDirectory, withIntermediateDirectories: true) + try "swift".write( + to: selectedDirectory.appendingPathComponent("ContentView.swift"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: root) } + + let results = await CommandPaletteQuickOpenFileSearch.searchCrossDirectory( + query: "Sources/", + rootDir: root.path + ) + + let selectedPath = selectedDirectory.resolvingSymlinksInPath().path + XCTAssertTrue( + results.contains { $0.url.resolvingSymlinksInPath().path == selectedPath }, + "Expected selected directory in results, got \(results.map { $0.url.path })" + ) + } + + func testCrossDirectoryDirectoryQueryIncludesSelectedNestedDirectory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-selected-nested-dir-\(UUID().uuidString)", isDirectory: true) + let selectedDirectory = root + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent("CommandPalette", isDirectory: true) + try FileManager.default.createDirectory(at: selectedDirectory, withIntermediateDirectories: true) + try "swift".write( + to: selectedDirectory.appendingPathComponent("QuickOpen.swift"), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: root) } + + let results = await CommandPaletteQuickOpenFileSearch.searchCrossDirectory( + query: "Sources/CommandPalette/", + rootDir: root.path + ) + + let selectedPath = selectedDirectory.resolvingSymlinksInPath().path + XCTAssertTrue( + results.contains { $0.url.resolvingSymlinksInPath().path == selectedPath }, + "Expected selected nested directory in results, got \(results.map { $0.url.path })" + ) + } + + func testFileSearchCrossDirectoryFuzzyMatchKeepsSlashInQuery() { + let match = CommandPaletteQuickOpenFileSearch.fuzzyMatch( + query: "cry/gnu", + candidate: "crypto/gnupg.md" + ) + XCTAssertNotNil(match) + XCTAssertFalse(match?.indices.isEmpty ?? true) + + XCTAssertNil( + CommandPaletteQuickOpenFileSearch.fuzzyScore( + query: "cry/to", + candidate: "crypto/gnupg.md" + ) + ) + } + + func testCrossDirectorySlashQueryDoesNotMatchInsideSinglePathComponent() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-slash-no-component-\(UUID().uuidString)", isDirectory: true) + let crypto = root.appendingPathComponent("crypto", isDirectory: true) + try FileManager.default.createDirectory(at: crypto, withIntermediateDirectories: true) + let file = crypto.appendingPathComponent("gnupg.md") + try "notes".write(to: file, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + let results = await CommandPaletteQuickOpenFileSearch.searchCrossDirectory( + query: "cry/to", + rootDir: root.path + ) + + let unexpectedPath = file.resolvingSymlinksInPath().path + XCTAssertFalse( + results.contains { $0.url.resolvingSymlinksInPath().path == unexpectedPath }, + "Expected cry/to not to match crypto/gnupg.md, got \(results.map { $0.url.path })" + ) + } + + func testSearchCrossDirectoryDeduplicatesSymlinkDirectoryCycles() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-symlink-cycle-\(UUID().uuidString)", isDirectory: true) + let realDirectory = root.appendingPathComponent("real", isDirectory: true) + try FileManager.default.createDirectory(at: realDirectory, withIntermediateDirectories: true) + let file = realDirectory.appendingPathComponent("needle.txt") + try "needle".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: realDirectory.appendingPathComponent("loop", isDirectory: true), + withDestinationURL: root + ) + defer { try? FileManager.default.removeItem(at: root) } + + let results = await CommandPaletteQuickOpenFileSearch.searchCrossDirectory( + query: "needle", + rootDir: root.path + ) + + let resolvedNeedlePath = file.resolvingSymlinksInPath().path + let matchingNeedles = results.filter { + $0.url.resolvingSymlinksInPath().path == resolvedNeedlePath + } + XCTAssertEqual(matchingNeedles.count, 1, "Expected one real needle result, got \(results.map { $0.url.path })") + } + + func testQuickOpenRelativePathUsesWorkspaceRoot() { + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-quick-open-relative-\(UUID().uuidString)", isDirectory: true) + let file = root + .appendingPathComponent("crypto", isDirectory: true) + .appendingPathComponent("gnupg.md") + + XCTAssertEqual( + CommandPaletteQuickOpenFileSearch.relativePath(url: file, rootDir: root.path), + "crypto/gnupg.md" + ) + } + + func testMatchingTermPathOnly() { + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/"), "") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("~"), "") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./"), "") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./Sources/"), "") + } + + func testMatchingTermNoPrefixIsCrossDirectory() { + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("main.swift"), "main.swift") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("ab"), "ab") + // "Sources/" has no path prefix → cross-directory, full term kept + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("Sources/"), "Sources/") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("Sources/Cont"), "Sources/Cont") + } + + func testMatchingTermPathWithSearch() { + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/b"), "b") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/Users/cha"), "cha") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("~/foo/bar/baz"), "baz") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./Sources/Cont"), "Cont") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./aaa/bbbb/"), "") + } + + func testMatchingTermDotSlashUsesWorkspaceRoot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-workspace-root-\(UUID().uuidString)", isDirectory: true) + let workspaceOnlyDirectory = root.appendingPathComponent("WorkspaceOnly", isDirectory: true) + try FileManager.default.createDirectory(at: workspaceOnlyDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertEqual( + CommandPaletteQuickOpenFileSearch.matchingTerm( + "./WorkspaceOnly", + workspaceRoot: root.path + ), + "" + ) + } + + func testMatchingTermDirectoryWithoutTrailingSlash() { + // /tmp always exists → browse mode (empty term). + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/tmp"), "") + // /tmp/nonexistent_xyz doesn't exist → search term is the filename. + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/tmp/nonexistent_xyz"), "nonexistent_xyz") + } + + func testMatchingTermDotfileSearch() throws { + // "/.t" → search for dotfiles matching "t" in root + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/.t"), ".t") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("/.bash"), ".bash") + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-quick-open-dotfile-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let previousDirectory = FileManager.default.currentDirectoryPath + XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(root.path)) + defer { FileManager.default.changeCurrentDirectoryPath(previousDirectory) } + + let dotTestDirectory = root.appendingPathComponent(".test", isDirectory: true) + let dotGitPath = root.appendingPathComponent(".git", isDirectory: true) + try FileManager.default.createDirectory(at: dotTestDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: dotGitPath, withIntermediateDirectories: true) + + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./.test"), "") + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./.git"), "") + + try FileManager.default.removeItem(at: dotGitPath) + try "gitdir: ../.git/worktrees/example\n".write(to: dotGitPath, atomically: true, encoding: .utf8) + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("./.git"), ".git") + + // "~/.t" → dotfile search in home + XCTAssertEqual(CommandPaletteQuickOpenFileSearch.matchingTerm("~/.t"), ".t") + } + + // MARK: resolve with ./ prefix + + func testResolveDotSlashEntersPathMode() { + let home = NSHomeDirectory() + let (_, _, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "./Sources/", + workspaceRoot: home + ) + XCTAssertTrue(isPathMode) + } + + func testResolveDotSlashWithSearch() { + let home = NSHomeDirectory() + let (_, _, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "./Sources/Cont", + workspaceRoot: home + ) + XCTAssertTrue(isPathMode) + } + + func testResolveNoPrefixIsCrossDirectory() { + let root = "/Users/test/Projects/MyApp" + let (_, _, isPathMode) = CommandPaletteQuickOpenFileSearch.resolve( + matchingQuery: "ab", + workspaceRoot: root + ) + XCTAssertFalse(isPathMode) + } + + // MARK: shouldSkipDirectoryForQuickOpen + + func testKnownSkipDirectories() { + let skips: [String] = [ + "node_modules", ".build", "DerivedData", + ".svn", ".hg", "__pycache__", ".cache", + "Pods", "Carthage", "build", "dist", "target", + ".vscode", ".idea", "vendor", + ] + for name in skips { + XCTAssertTrue( + CommandPaletteQuickOpenFileSearch.shouldSkipDirectory(name), + "\(name) should be skipped" + ) + } + } + + func testNormalDirectoriesNotSkipped() { + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory("Sources")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory("src")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory(".git")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory(".test")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory("lib")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory("MyApp")) + XCTAssertFalse(CommandPaletteQuickOpenFileSearch.shouldSkipDirectory("Tests")) + } +} diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteRequestKindTests.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteRequestKindTests.swift index 59d6518aa7b0..ea701426c716 100644 --- a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteRequestKindTests.swift +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteRequestKindTests.swift @@ -7,6 +7,7 @@ import Testing @Test func notificationNamesMatchLegacyLiterals() { #expect(CommandPaletteRequestKind.commands.notificationName == "cmux.commandPaletteRequested") #expect(CommandPaletteRequestKind.switcher.notificationName == "cmux.commandPaletteSwitcherRequested") + #expect(CommandPaletteRequestKind.fileSearch.notificationName == "cmux.commandPaletteFileSearchRequested") #expect(CommandPaletteRequestKind.renameTab.notificationName == "cmux.commandPaletteRenameTabRequested") #expect(CommandPaletteRequestKind.renameWorkspace.notificationName == "cmux.commandPaletteRenameWorkspaceRequested") #expect( diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 70037d0be683..b39063f41d4e 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -36,7 +36,8 @@ extension ShortcutAction { case .newTab: return ShortcutStroke(key: "n", command: true) case .newBrowserWorkspace: return ShortcutStroke(key: "n", command: true, option: true) case .openFolder: return ShortcutStroke(key: "o", command: true) - case .reopenPreviousSession: return ShortcutStroke(key: "o", command: true, shift: true) + case .quickOpenFile: return ShortcutStroke(key: "o", command: true, shift: true) + case .reopenPreviousSession: return nil case .goToWorkspace: return ShortcutStroke(key: "p", command: true) case .commandPalette: return ShortcutStroke(key: "p", command: true, shift: true) case .commandPaletteNext: return ShortcutStroke(key: "n", control: true) diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 50fa11b58056..7f5796fec61e 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -28,6 +28,7 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case openFolder case reopenPreviousSession case goToWorkspace + case quickOpenFile case commandPalette case commandPaletteNext case commandPalettePrevious @@ -163,7 +164,7 @@ extension ShortcutAction { case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch, .newWindow, .closeWindow, .toggleFullScreen, .quit: return .app - case .toggleSidebar, .newTab, .newBrowserWorkspace, .openFolder, .reopenPreviousSession, .goToWorkspace, + case .toggleSidebar, .newTab, .newBrowserWorkspace, .openFolder, .quickOpenFile, .reopenPreviousSession, .goToWorkspace, .commandPalette, .commandPaletteNext, .commandPalettePrevious, .sendFeedback, .showNotifications, .jumpToUnread, .toggleUnread, .markOldestUnreadAndJumpNext, .focusRightSidebar, .switchRightSidebarToFiles, .switchRightSidebarToFind, @@ -303,6 +304,8 @@ extension ShortcutAction { case .newBrowserWorkspace: return String(localized: "shortcut.newBrowserWorkspace.label", defaultValue: "New Browser Workspace") case .openFolder: return "Open Folder" + case .quickOpenFile: + return String(localized: "shortcut.quickOpenFile.label", defaultValue: "Quick Open…") case .reopenPreviousSession: return "Restore Previous App Launch" case .goToWorkspace: return "Go to Workspace…" case .commandPalette: return "Command Palette…" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 87ec6d72f219..40b6af15f4ba 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -58557,6 +58557,75 @@ } } }, + "commandPalette.kind.directory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディレクトリ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目录" + } + } + } + }, + "commandPalette.kind.file": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件" + } + } + } + }, + "commandPalette.kind.openInFinder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finderで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Finder 中打开" + } + } + } + }, "commandPalette.kind.markdown": { "extractionState": "manual", "localizations": { @@ -60298,6 +60367,52 @@ } } }, + "commandPalette.search.fileSearchEmpty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No files found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルが見つかりません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到文件" + } + } + } + }, + "commandPalette.search.fileSearchPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search files by name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル名で検索" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按文件名搜索" + } + } + } + }, "commandPalette.search.switcherEmpty": { "extractionState": "manual", "localizations": { @@ -78690,6 +78805,29 @@ } } }, + "menu.file.quickOpenFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick Open…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クイックオープン…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速打开…" + } + } + } + }, "menu.file.reopenPreviousSession": { "extractionState": "manual", "localizations": { @@ -129963,6 +130101,29 @@ } } }, + "shortcut.quickOpenFile.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick Open…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クイックオープン…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速打开…" + } + } + } + }, "shortcut.reopenPreviousSession.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fb378e8ccf68..e996603ad027 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -12731,6 +12731,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .quickOpenFile) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + postCommandPaletteRequest( + kind: .fileSearch, + preferredWindow: targetWindow, + source: "shortcut.quickOpenFile" + ) + return true + } + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { return true @@ -12741,6 +12752,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { return true } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.quickOpenFile]) { + return true + } } if shouldConsumeShortcutWhileCommandPaletteVisible( @@ -12927,6 +12944,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .quickOpenFile) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + postCommandPaletteRequest( + kind: .fileSearch, + preferredWindow: targetWindow, + source: "shortcut.quickOpenFile" + ) + return true + } + if matchConfiguredShortcut(event: event, action: .quit) { return handleQuitShortcutWarning() } diff --git a/Sources/CommandPaletteQuickOpenFileSearch.swift b/Sources/CommandPaletteQuickOpenFileSearch.swift new file mode 100644 index 000000000000..a300fbe22a35 --- /dev/null +++ b/Sources/CommandPaletteQuickOpenFileSearch.swift @@ -0,0 +1,124 @@ +import AppKit +import CmuxCommandPalette +import Foundation +import UniformTypeIdentifiers + +extension ContentView { + nonisolated static func commandPaletteFileSearchMatchingTerm( + _ matchingQuery: String, + workspaceRoot: String? = nil + ) -> String { + CommandPaletteQuickOpenFileSearch.matchingTerm(matchingQuery, workspaceRoot: workspaceRoot) + } + + static func commandPaletteFileSearchFingerprint(query: String) -> Int { + CommandPaletteQuickOpenFileSearch.fingerprint(query: query) + } + + static func commandPaletteFileSearchDedupFingerprint( + query: String, + isCrossDirectory: Bool, + workspaceRoot: String? = nil + ) -> Int? { + guard isCrossDirectory, + commandPaletteListScope(for: query) == .fileSearch else { + return nil + } + return CommandPaletteQuickOpenFileSearch.crossDirectoryDedupFingerprint( + query: query, + workspaceRoot: workspaceRoot + ) + } + + static func commandPaletteFileSearchResolve( + matchingQuery: String, + workspaceRoot: String? + ) -> CommandPaletteQuickOpenFileSearch.ResolvedPath { + CommandPaletteQuickOpenFileSearch.resolve(matchingQuery: matchingQuery, workspaceRoot: workspaceRoot) + } + + static func resolveLongestExistingDirectory(_ path: String) -> (existingDir: String?, remainder: String) { + CommandPaletteQuickOpenFileSearch.resolveLongestExistingDirectory(path) + } + + static func commandPaletteFileSearchPathForDirectory( + _ url: URL, + rootDir: String, + usePathPrefix: Bool = true + ) -> String { + CommandPaletteQuickOpenFileSearch.pathForDirectory( + url, + rootDir: rootDir, + usePathPrefix: usePathPrefix + ) + } + + /// Open a file for viewing/editing, avoiding accidental execution of scripts + /// or binaries in a terminal. + static func openFileInDefaultEditor(_ url: URL) { + Task(priority: .userInitiated) { + let action = CommandPaletteQuickOpenFileSearch.openAction(for: url) + await Self.performQuickOpenFileOpenAction(action) + } + } + + nonisolated static func quickOpenFileOpenAction( + for url: URL + ) async -> CommandPaletteQuickOpenFileOpenAction { + CommandPaletteQuickOpenFileSearch.openAction(for: url) + } + + @MainActor + static func performQuickOpenFileOpenAction(_ action: CommandPaletteQuickOpenFileOpenAction) { + switch action { + case .open(let url): + NSWorkspace.shared.open(url) + case .reveal(let url): + NSWorkspace.shared.activateFileViewerSelecting([url]) + case .textEditor(let url): + guard let editorURL = NSWorkspace.shared.urlForApplication(toOpen: UTType.plainText) else { + NSWorkspace.shared.open(url) + return + } + NSWorkspace.shared.open( + [url], + withApplicationAt: editorURL, + configuration: NSWorkspace.OpenConfiguration() + ) + } + } + + nonisolated static func isDirectory(_ url: URL) -> Bool { + CommandPaletteQuickOpenFileSearch.isDirectory(url) + } + + nonisolated static func listFilesInDirectory(_ dir: String, maxCount: Int) -> [URL] { + CommandPaletteQuickOpenFileSearch.listFiles(inDirectory: dir, maxCount: maxCount) + } + + nonisolated static func shouldSkipDirectoryForQuickOpen(_ name: String) -> Bool { + CommandPaletteQuickOpenFileSearch.shouldSkipDirectory(name) + } + + nonisolated static func searchCrossDirectory( + query: String, + rootDir: String + ) async -> [CommandPaletteQuickOpenScoredFile] { + await CommandPaletteQuickOpenFileSearch.searchCrossDirectory(query: query, rootDir: rootDir) + } + + nonisolated static func fileSearchCrossDirectoryFuzzyScore(query: String, candidate: String) -> Int? { + CommandPaletteQuickOpenFileSearch.fuzzyScore(query: query, candidate: candidate) + } + + nonisolated static func fileSearchCrossDirectoryFuzzyMatch( + query: String, + candidate: String + ) -> (score: Int, indices: Set)? { + CommandPaletteQuickOpenFileSearch.fuzzyMatch(query: query, candidate: candidate) + } + + nonisolated static func quickOpenRelativePath(url: URL, rootDir: String) -> String { + CommandPaletteQuickOpenFileSearch.relativePath(url: url, rootDir: rootDir) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 16e062dc9198..8e3b62decf75 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1338,6 +1338,8 @@ struct ContentView: View { ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" nonisolated private static let commandPaletteCommandsPrefix = ">" + nonisolated private static let commandPaletteFileSearchPrefix = "@" + private static var fileSearchDedupFingerprint: Int? private static let commandPaletteVisiblePreviewResultLimit = 48 private static let commandPaletteVisiblePreviewCandidateLimit = 128 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 @@ -2907,6 +2909,17 @@ struct ContentView: View { openCommandPaletteSwitcher() }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteFileSearchRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteFileSearch() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .defaultTerminalRegistrationDidChange)) { _ in refreshCachedDefaultTerminalStatus() }) @@ -4814,10 +4827,13 @@ struct ContentView: View { ) } - nonisolated private static func commandPaletteListScope(for query: String) -> CommandPaletteListScope { + nonisolated static func commandPaletteListScope(for query: String) -> CommandPaletteListScope { if query.hasPrefix(Self.commandPaletteCommandsPrefix) { return .commands } + if query.hasPrefix(Self.commandPaletteFileSearchPrefix) { + return .fileSearch + } return .switcher } @@ -4848,6 +4864,8 @@ struct ContentView: View { return commandPaletteSearchAllSurfaces ? String(localized: "commandPalette.search.switcherPlaceholderAllSurfaces", defaultValue: "Search workspaces and surfaces") : String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces") + case .fileSearch: + return String(localized: "commandPalette.search.fileSearchPlaceholder", defaultValue: "Search files by name") } } @@ -4859,6 +4877,8 @@ struct ContentView: View { return commandPaletteSearchAllSurfaces ? String(localized: "commandPalette.search.switcherEmptyAllSurfaces", defaultValue: "No workspaces or surfaces match your search.") : String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.") + case .fileSearch: + return String(localized: "commandPalette.search.fileSearchEmpty", defaultValue: "No files found") } } @@ -4896,7 +4916,7 @@ struct ContentView: View { ) } - nonisolated private static func commandPaletteQueryForMatching( + nonisolated static func commandPaletteQueryForMatching( query: String, scope: CommandPaletteListScope ) -> String { @@ -4906,6 +4926,9 @@ struct ContentView: View { return suffix.trimmingCharacters(in: .whitespacesAndNewlines) case .switcher: return query.trimmingCharacters(in: .whitespacesAndNewlines) + case .fileSearch: + let suffix = String(query.dropFirst(Self.commandPaletteFileSearchPrefix.count)) + return suffix.trimmingCharacters(in: .whitespacesAndNewlines) } } @@ -4926,6 +4949,14 @@ struct ContentView: View { return commandPaletteCommands(commandsContext: commandsContext ?? commandPaletteCachedCommandsContext()) case .switcher: return commandPaletteSwitcherEntries(includeSurfaces: includeSurfaces) + case .fileSearch: + let mq = commandPaletteQueryForMatching + let (cd, rawSearchTerm, isPathMode) = Self.commandPaletteFileSearchResolve( + matchingQuery: mq, workspaceRoot: resolvedFileSearchWorkspaceRoot) + if isPathMode && !rawSearchTerm.contains("/") { + return commandPaletteFileSearchPathEntries(matchingQuery: mq, currentDir: cd) + } + return [] } } @@ -4968,11 +4999,41 @@ struct ContentView: View { return } - let entries = commandPaletteEntries( - for: scope, - includeSurfaces: includeSurfaces, - commandsContext: commandsContext - ) + if cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint { + // Nuke stale index; the async build will create a new one. + // Without this, searches use old indexed text (e.g. absolute + // paths from a previous query) and produce wrong matches. + commandPaletteNucleoSearchIndex = nil + } + + let entries: [CommandPaletteCommand] + if scope == .fileSearch { + let matchingQuery = Self.commandPaletteQueryForMatching( + query: effectiveQuery, scope: .fileSearch) + let (currentDir, rawSearchTerm, isPathMode) = Self.commandPaletteFileSearchResolve( + matchingQuery: matchingQuery, workspaceRoot: resolvedFileSearchWorkspaceRoot) + if isPathMode && !rawSearchTerm.contains("/") { + entries = commandPaletteFileSearchPathEntries( + matchingQuery: matchingQuery, currentDir: currentDir) + } else { + // Cross-directory or invalid path. Clear stale corpus so + // sync seeding doesn't show results from a previous query. + // Still record the fingerprint so repeated calls on the + // same query are no-ops (matching commands/switcher behavior). + cachedCommandPaletteScope = scope + cachedCommandPaletteFingerprint = fingerprint + commandPaletteSearchCorpus = [] + commandPaletteSearchCommandsByID = [:] + commandPaletteSearchCorpusByID = [:] + return + } + } else { + entries = commandPaletteEntries( + for: scope, + includeSurfaces: includeSurfaces, + commandsContext: commandsContext + ) + } commandPaletteSearchCommandsByID = CommandPaletteSearchOrchestrator.firstValueDictionary( entries, keyedBy: \.id @@ -5136,6 +5197,27 @@ struct ContentView: View { scope: scope ) + // Cross-directory: defer to background task. + var fsIsCrossDirectory = false + if scope == .fileSearch { + let (_, _, p) = Self.commandPaletteFileSearchResolve( + matchingQuery: matchingQuery, workspaceRoot: resolvedFileSearchWorkspaceRoot) + fsIsCrossDirectory = !p + // Cross-directory BFS is heavy. Two ContentView instances receive + // the same query change; let only the first one schedule work. + // Path-mode queries such as "@" and "@/" must never dedup because + // they synchronously seed the visible directory entries. + if let fp = Self.commandPaletteFileSearchDedupFingerprint( + query: effectiveQuery, + isCrossDirectory: fsIsCrossDirectory, + workspaceRoot: resolvedFileSearchWorkspaceRoot + ) { + if Self.fileSearchDedupFingerprint == fp { return } + Self.fileSearchDedupFingerprint = fp + } else { + Self.fileSearchDedupFingerprint = nil + } + } refreshCommandPaletteSearchCorpus( force: forceSearchCorpusRefresh, query: effectiveQuery @@ -5149,10 +5231,17 @@ struct ContentView: View { let searchIndex = commandPaletteNucleoSearchIndex let commandsByID = commandPaletteSearchCommandsByID let usageHistory = commandPaletteUsageHistoryByCommandId - let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(matchingQuery).isEmpty + let nucleoQuery: String = scope == .fileSearch + ? Self.commandPaletteFileSearchMatchingTerm( + matchingQuery, + workspaceRoot: resolvedFileSearchWorkspaceRoot + ) + : matchingQuery + let preparedSearchQuery = CommandPaletteFuzzyMatcher.preparedQuery(nucleoQuery) + let queryIsEmpty = preparedSearchQuery.isEmpty let historyTimestamp = Date().timeIntervalSince1970 let additionalScoreBoost: (String, Bool) -> Int = { commandId, _ in - Self.commandPaletteForkPriorityBoost(commandId: commandId, query: matchingQuery) + Self.commandPaletteForkPriorityBoost(commandId: commandId, query: nucleoQuery) } let visiblePreviewResultLimit = Self.commandPaletteVisiblePreviewResultLimit if preservePendingActivation { @@ -5164,8 +5253,18 @@ struct ContentView: View { commandPalettePendingActivation = nil } cancelCommandPaletteSearch() + // File search entries change with every query, so existing visible + // results for the same scope are always stale. Allow synchronous + // seeding whenever the corpus is small enough. + // Cross-directory: always skip sync seeding — corpus is built by + // the background task. + let hasCurrentVisibleResults: Bool = { + if fsIsCrossDirectory { return true } + if scope == .fileSearch { return false } + return commandPaletteVisibleResultsScope == scope + }() if CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( - hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope, + hasVisibleResultsForScope: hasCurrentVisibleResults, hasSearchIndex: searchIndex != nil, corpusCount: searchCorpus.count ) { @@ -5173,14 +5272,20 @@ struct ContentView: View { searchIndex: searchIndex, searchCorpus: searchCorpus, searchCorpusByID: searchCorpusByID, - query: matchingQuery, + query: nucleoQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp, - additionalScoreBoost: additionalScoreBoost + additionalScoreBoost: additionalScoreBoost, + resultLimit: scope == .fileSearch ? 30 : nil ) + // Nucleo includes score-0 non-matches; Swift fallback excludes + // them (returns nil). Align: drop score-0 for fileSearch. + let filteredMatches: [CommandPaletteResolvedSearchMatch] = (scope == .fileSearch && !queryIsEmpty) + ? matches.filter { $0.score > 0 } + : matches cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( - matches: matches, + matches: filteredMatches, commandsByID: commandsByID ) let resultIDs = cachedCommandPaletteResults.map(\.id) @@ -5223,7 +5328,70 @@ struct ContentView: View { isCommandPaletteSearchPending = true syncCommandPaletteOverlayCommandListState() - commandPaletteSearchTask = Task.detached(priority: .userInitiated) { + let capturedMatchingQuery = matchingQuery + let capturedFSRoot = resolvedFileSearchWorkspaceRoot + commandPaletteSearchTask = Task(priority: .userInitiated) { + // Cross-directory: parallel BFS + scoring on background. + if fsIsCrossDirectory { + let rootDir = capturedFSRoot ?? NSHomeDirectory() + let sq = Self.commandPaletteFileSearchMatchingTerm( + capturedMatchingQuery, + workspaceRoot: rootDir + ) + let scored = await Self.searchCrossDirectory(query: sq, rootDir: rootDir) + await MainActor.run { + guard commandPaletteSearchRequestID == requestID, + isCommandPalettePresented else { return } + var titleIndicesByIndex: [Int: Set] = [:] + let entries = scored.enumerated().map { (i, item) -> CommandPaletteCommand in + let name = item.url.lastPathComponent + let rp = item.url.path.hasPrefix(rootDir + "/") + ? String(item.url.path.dropFirst(rootDir.count + 1)) : item.url.path + let isDir = Self.isDirectory(item.url) + let matchCandidate = isDir ? rp + "/" : rp + let isSelectedDirectory = isDir + && matchCandidate.lowercased() == sq.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let title = isSelectedDirectory ? "." : rp + titleIndicesByIndex[i] = isSelectedDirectory + ? [] + : Self.fileSearchCrossDirectoryFuzzyMatch(query: sq, candidate: matchCandidate)?.indices + return CommandPaletteCommand( + id: "file.quickopen.\(item.url.absoluteString.hashValue)", + rank: isDir ? 0 : item.depth * 1000 + i, + title: title, + subtitle: isSelectedDirectory ? rp : "", + shortcutHint: nil, + kindLabel: isDir + ? String(localized: "commandPalette.kind.directory", defaultValue: "Directory") + : String(localized: "commandPalette.kind.file", defaultValue: "File"), + keywords: [name] + rp.split(separator: "/").map(String.init), + dismissOnRun: !isDir || isSelectedDirectory, + action: { + if isSelectedDirectory { + NSWorkspace.shared.open(item.url) + } else if isDir { + let p = Self.commandPaletteFileSearchPathForDirectory( + item.url, rootDir: rootDir, usePathPrefix: false) + self.commandPaletteQuery = Self.commandPaletteFileSearchPrefix + p + } else { Self.openFileInDefaultEditor(item.url) } + } + ) + } + let results = entries.enumerated().map { (i, cmd) in + CommandPaletteSearchResult(command: cmd, score: scored[i].score, + titleMatchIndices: titleIndicesByIndex[i] ?? []) + } + cachedCommandPaletteResults = results + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults(results, scope: scope, fingerprint: fingerprint) + commandPaletteResultsRevision &+= 1 + } + return + } + let previewMatches = shouldApplyPreviewResults ? CommandPaletteSearchOrchestrator.previewSearchMatches( scope: scope, @@ -5231,7 +5399,7 @@ struct ContentView: View { searchCorpus: searchCorpus, candidateCommandIDs: previewCandidateCommandIDs, searchCorpusByID: searchCorpusByID, - query: matchingQuery, + query: nucleoQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp, @@ -5281,14 +5449,19 @@ struct ContentView: View { searchIndex: searchIndex, searchCorpus: searchCorpus, searchCorpusByID: searchCorpusByID, - query: matchingQuery, + query: nucleoQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp, additionalScoreBoost: additionalScoreBoost, + resultLimit: scope == .fileSearch ? 30 : nil, shouldCancel: { Task.isCancelled } ) + let filteredMatches: [CommandPaletteResolvedSearchMatch] = (scope == .fileSearch && !queryIsEmpty) + ? matches.filter { $0.score > 0 } + : matches + guard !Task.isCancelled else { return } await MainActor.run { @@ -5307,7 +5480,7 @@ struct ContentView: View { } cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( - matches: matches, + matches: filteredMatches, commandsByID: commandPaletteSearchCommandsByID ) let resultIDs = cachedCommandPaletteResults.map(\.id) @@ -5359,6 +5532,8 @@ struct ContentView: View { ) case .switcher: return commandPaletteSwitcherEntriesFingerprint(includeSurfaces: includeSurfaces) + case .fileSearch: + return commandPaletteFileSearchFingerprint() } } @@ -5406,6 +5581,85 @@ struct ContentView: View { return CommandPaletteSwitcherFingerprintContext.fingerprint(windowContexts: fingerprintContexts) } + // MARK: - File Search (Quick Open) + + private func commandPaletteFileSearchFingerprint() -> Int { + Self.commandPaletteFileSearchFingerprint(query: commandPaletteQuery) + } + + /// Path mode: single-directory listing. Called directly from + /// refreshCommandPaletteSearchCorpus. + private func commandPaletteFileSearchPathEntries( + matchingQuery: String, currentDir: String + ) -> [CommandPaletteCommand] { + guard let rootDir = resolvedFileSearchWorkspaceRoot, !rootDir.isEmpty else { return [] } + let resolvedDir = currentDir + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: resolvedDir) else { return [] } + + var entries: [CommandPaletteCommand] = [] + if resolvedDir != rootDir { + entries.append(Self.commandPaletteFileSearchDotEntry(currentDir: resolvedDir)) + } + let nucleoTerm = Self.commandPaletteFileSearchMatchingTerm( + matchingQuery, + workspaceRoot: rootDir + ) + let fileURLs = Self.listFilesInDirectory(resolvedDir, maxCount: 1000) + .filter { nucleoTerm.isEmpty ? !$0.lastPathComponent.hasPrefix(".") : true } + for url in fileURLs { + let isDir = Self.isDirectory(url) + let name = url.lastPathComponent + entries.append(CommandPaletteCommand( + id: "file.quickopen.\(url.absoluteString.hashValue)", + rank: isDir ? 0 : 1, + title: name, subtitle: "", + shortcutHint: nil, + kindLabel: isDir + ? String(localized: "commandPalette.kind.directory", defaultValue: "Directory") + : String(localized: "commandPalette.kind.file", defaultValue: "File"), + keywords: [], + dismissOnRun: !isDir, + action: { + if isDir { + let newPath = Self.commandPaletteFileSearchPathForDirectory( + url, rootDir: rootDir, usePathPrefix: true + ) + self.commandPaletteQuery = Self.commandPaletteFileSearchPrefix + newPath + } else { + Self.openFileInDefaultEditor(url) + } + } + )) + } + return entries + } + + private var resolvedFileSearchWorkspaceRoot: String? { + tabManager.selectedWorkspace?.currentDirectory + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + } + + private static func commandPaletteFileSearchDotEntry(currentDir: String) -> CommandPaletteCommand { + let url = URL(fileURLWithPath: currentDir, isDirectory: true) + return CommandPaletteCommand( + id: "file.quickopen.dot.\(currentDir.hashValue)", + rank: -1, + title: ".", + subtitle: currentDir, + shortcutHint: nil, + kindLabel: String(localized: "commandPalette.kind.directory", defaultValue: "Directory"), + keywords: [".", "open", "finder", "reveal"], + dismissOnRun: true, + action: { + NSWorkspace.shared.open(url) + } + ) + } + + // MARK: - Highlighted Title + private static func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { guard !matchedIndices.isEmpty else { return Text(title).foregroundColor(.primary) @@ -8747,8 +9001,18 @@ struct ContentView: View { handleCommandPaletteListRequest(scope: .switcher) } + private func openCommandPaletteFileSearch() { + handleCommandPaletteListRequest(scope: .fileSearch) + } + private func handleCommandPaletteListRequest(scope: CommandPaletteListScope) { - let initialQuery = (scope == .commands) ? Self.commandPaletteCommandsPrefix : "" + let initialQuery: String = { + switch scope { + case .commands: return Self.commandPaletteCommandsPrefix + case .fileSearch: return Self.commandPaletteFileSearchPrefix + case .switcher: return "" + } + }() guard isCommandPalettePresented else { presentCommandPalette(initialQuery: initialQuery) return @@ -8912,6 +9176,7 @@ struct ContentView: View { commandPaletteRestoreFocusTarget = nil } isCommandPalettePresented = true + Self.fileSearchDedupFingerprint = nil commandPaletteForkableAgentActivePanelKey = nil refreshCommandPaletteUsageHistory() resetCommandPaletteListState(initialQuery: initialQuery) @@ -16824,4 +17089,3 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { } } } - diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 8f4756b7337b..022502c33bd2 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -75,6 +75,7 @@ enum KeyboardShortcutSettings { case newTab case newBrowserWorkspace case openFolder + case quickOpenFile case reopenPreviousSession case goToWorkspace case commandPalette @@ -194,6 +195,7 @@ enum KeyboardShortcutSettings { case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") case .newBrowserWorkspace: return String(localized: "shortcut.newBrowserWorkspace.label", defaultValue: "New Browser Workspace") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .quickOpenFile: return String(localized: "shortcut.quickOpenFile.label", defaultValue: "Quick Open File…") case .reopenPreviousSession: return String(localized: "shortcut.reopenPreviousSession.label", defaultValue: "Restore Previous App Launch") case .goToWorkspace: return String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…") case .commandPalette: return String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…") @@ -339,8 +341,10 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: true, control: false) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) - case .reopenPreviousSession: + case .quickOpenFile: return StoredShortcut(key: "o", command: true, shift: true, option: false, control: false) + case .reopenPreviousSession: + return .unbound case .goToWorkspace: return StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) case .commandPalette: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f8b7fb1ece0f..a2a2c83826e0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6036,6 +6036,7 @@ extension Notification.Name { static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteFileSearchRequested = Notification.Name("cmux.commandPaletteFileSearchRequested") static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested") static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested") static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 83ac0c72eaf2..bbe25adab754 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -757,6 +757,11 @@ struct cmuxApp: App { NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) } + splitCommandButton(title: String(localized: "menu.file.quickOpenFile", defaultValue: "Quick Open…"), shortcut: menuShortcut(for: .quickOpenFile)) { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteFileSearchRequested, object: targetWindow) + } + splitCommandButton(title: String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…"), shortcut: menuShortcut(for: .commandPalette)) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 40e4608da804..729e81feb1f7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -309,6 +309,7 @@ C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */; }; C0DEC0DE000000000000F204 /* CommandPaletteNucleoFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */; }; C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */; }; + C0DE59820000000000000001 /* CommandPaletteQuickOpenFileSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE59820000000000000002 /* CommandPaletteQuickOpenFileSearch.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */; }; C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */; }; @@ -1164,6 +1165,7 @@ C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFITests.swift; sourceTree = ""; }; C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFixtures.swift; sourceTree = ""; }; C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteOverlay.swift; sourceTree = ""; }; + C0DE59820000000000000002 /* CommandPaletteQuickOpenFileSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteQuickOpenFileSearch.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSettingsToggle.swift; sourceTree = ""; }; C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSettingsToggleTests.swift; sourceTree = ""; }; @@ -1996,10 +1998,11 @@ A50019B1 /* SettingsSearchAliases.swift */, D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */, D36090010000000000000002 /* SettingsWindowPresenter.swift */, - D36090020000000000000002 /* NSWindow+CmuxPeerWindow.swift */, - C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */, - A5001012 /* ContentView.swift */, - C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */, + D36090020000000000000002 /* NSWindow+CmuxPeerWindow.swift */, + C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */, + A5001012 /* ContentView.swift */, + C0DE59820000000000000002 /* CommandPaletteQuickOpenFileSearch.swift */, + C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */, C0DE46030000000000000002 /* CMUXSidebarExtensionBrowserPanel.swift */, E4C001000000000000000002 /* ExtensionSidebarWorkspaceRowView.swift */, E4C001000000000000000004 /* ExtensionWorktreePrototype.swift */, @@ -3280,6 +3283,7 @@ A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, + C0DE59820000000000000001 /* CommandPaletteQuickOpenFileSearch.swift in Sources */, C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */, C1713002C1713002C1713002 /* CommandPaletteShortcutRouting.swift in Sources */, 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, diff --git a/docs/quick-open-file.md b/docs/quick-open-file.md new file mode 100644 index 000000000000..39e97941659a --- /dev/null +++ b/docs/quick-open-file.md @@ -0,0 +1,310 @@ +# Quick Open 搜索逻辑 + +## 入口与触发 + +- **快捷键** `Cmd+Shift+O`(默认)或 **File → Quick Open…** → 发送 `.commandPaletteFileSearchRequested` 通知 +- `ContentView` 收到通知后调用 `openCommandPaletteFileSearch()` → `handleCommandPaletteListRequest(scope: .fileSearch)` +- 初始 query 设为 `@`,输入框前缀触发 `.fileSearch` scope + +## Scope 判定(`commandPaletteListScope`) + +``` +query 前缀 scope +──────────────────────── + > .commands (命令面板) + @ .fileSearch(文件搜索) + 其他 .switcher (工作区切换) +``` + +`>` 优先于 `@`(如 `>@` → `.commands`)。在 switcher 中输入 `@` 会自动切到 fileSearch。 + +## Query 提取 + +- `commandPaletteQueryForMatching`:去掉 scope 前缀(`>` 或 `@`),返回完整 suffix +- `commandPaletteFileSearchMatchingTerm`:从 matching query 提取搜索词。path 模式取 last `/` 之后的部分(若解析到已存在目录则返回空),cross-directory 返回全文 + +| 输入 | matching query | matching term | +|---|---|---| +| `@` | `""` (空) | `""` | +| `@/Users/cha` | `"/Users/cha"` | `"cha"`(`/Users` 存在,剩余 `cha` 为搜索词) | +| `@./Sources/Cont` | `"./Sources/Cont"` | `"Cont"` | +| `@main.swift` | `"main.swift"` | `"main.swift"` | + +## 搜索模式分流(`commandPaletteFileSearchResolve`) + +``` +@ + 空/空白 + → path 模式: currentDir = workspace root, searchTerm = "" + +@ + ~...、/... 或 ./... + → path 模式: 展开 ~,resolve 最长存在的目录前缀 + currentDir = 已存在目录, searchTerm = 剩余部分 + +@ + 其他字符 + → cross-directory 模式: currentDir = workspace root, searchTerm = 完整内容 +``` + +workspace root 为空时回退到 `NSHomeDirectory()`。 + +### 路径解析(`resolveLongestExistingDirectory`) + +从完整路径的末尾向前逐级尝试,找到第一个真实存在的目录: + +``` +输入: /Users/changtang/Dev/cmux/Sources + +尝试: + /Users/changtang/Dev/cmux/Sources → 存在 ✓ → 返回此目录, remainder="" + (若不存在则继续向前找 /Users/changtang/Dev/cmux, 以此类推) + +输入: /Users/cha + /Users/cha → 不存在 + /Users → 存在 ✓ → 返回 /Users, remainder="cha" +``` + +## path 模式 vs cross-directory 模式 + +| | path 模式 | cross-directory 模式 | +|---|---|---| +| 触发 | `@`、`@/`、`@~`、`@./` 前缀 | 其他输入(如 `@ab`、`@cry/gnu`) | +| 搜索范围 | **单层目录**(`listFilesInDirectory`) | **并行 BFS** + 即时打分 + fast quit | +| 搜索方式 | 单层内 nucleo fuzzy match | raw query 对 workspace 相对路径做 fuzzy match | +| `.` 条目 | 非 root 时追加 | 无 | +| 排序 | 目录优先,名称字母序 | 按 score 降序(即时 top-K) | +| 目录选择 | 选目录进入下一层 path 模式 | workspace 内目录保持 cross-directory 查询 | + +### Cross-directory 搜索(`searchCrossDirectory`) + +结构化并行 BFS + fast quit。cross-directory 不对输入 query 分词;搜索目标串是每个文件/目录从 workspace root 起算的相对路径。`/` 按普通字符参与匹配,因此 `cry/gnu` 可以命中 `crypto/gnupg.md`,但 `cry/to` 不会只因为 `crypto` 内部有 `to` 而命中。 + +``` +调用任务: BFS(root, depth=0) 收集顶层子目录,同时为 root 级文件打分入全局 top-K + +withTaskGroup(topLevelDirs): + 每个 child task: BFS(dir, scoring=on): + queue = [(dir, depth)] + while queue: + if Task.isCancelled: return local top-K + dir = dequeue + for entry in contentsOfDirectory(dir): + if hidden or skip-list: continue + score = fileSearchCrossDirectoryFuzzyScore(query, relativePath) + if score > 0: insert local top-K min-heap (K=30) + if isDir: enqueue (entry, depth+1) + + // Fast quit(三个条件同时满足) + if query.count >= 3 + && heap.count >= 30 + && heap.min.score >= ideal × 0.618 + && scanned >= 10,000: + break thread + +逐个合并 child task 的 local top-K → 全局 top-30 +``` + +`fileSearchCrossDirectoryFuzzyMatch` 返回 score 和高亮 indices,cross-directory 结果排序和 title 高亮使用同一套匹配规则。 + +打分规则: + +- query 字符必须按顺序出现在相对路径里,`/` 不做特殊解析,只作为必须匹配的字符 +- 每个命中字符基础加分 +- 命中字符串开头或 `/` 后面的路径分段开头时加分 +- 连续命中加分,跳跃匹配按距离扣分 +- 候选相对路径越长,尾部长度差扣分越多 + +### Fast Quit 常量 + +| 常量 | 值 | 说明 | +|---|---|---| +| `fastQuitKeepMax` | 30 | top-K 大小 | +| `fastQuitMinQueryChars` | 3 | query < 3 字符不启用早停 | +| `fastQuitRatio` | 0.618 | 黄金比,最差分 ≥ ideal × ratio 才饱和 | +| `fastQuitMinScan` | 10,000 | 至少扫够样本再决策 | + +### 跳过的目录(`shouldSkipDirectoryForQuickOpen`) + +``` +.svn .hg .build .cache .idea .vscode .vs .swiftpm +.eggs .tox .dart_tool .next .nuxt +node_modules __pycache__ vendor bower_components +Pods Carthage DerivedData +build dist target +``` + +## 条目结构(`CommandPaletteCommand`) + +每条 entry: + +| 字段 | 文件 | 目录 | `.` | +|---|---|---|---| +| `id` | `file.quickopen.` | 同左 | `file.quickopen.dot.` | +| `rank` | 1 | 0 | -1 | +| `title` | path 模式: 文件名;cross-directory: 从 workspace root 的相对路径 | 同左 | `.` | +| `subtitle` | 空 | 空 | 当前目录绝对路径 | +| `kindLabel` | File | Directory | Directory | +| `keywords` | path 模式: `[]`(空);cross-directory: `[文件名] + 路径分段` | 同左 | `[".", "open", "finder", "reveal"]` | +| `dismissOnRun` | true | **false** | true | +| `action` | `openFileInDefaultEditor` | 更新 `commandPaletteQuery` | `NSWorkspace.shared.open` | + +### Path 模式触发规则 + +`commandPaletteFileSearchResolve` 按优先级判断: + +1. `~` 或 `/` 前缀 → path 模式(绝对/home 路径) +2. `./` 前缀 → path 模式(workspace 相对路径) +3. 其他 → cross-directory 模糊搜索(包括 `foo/`、`foo/bar` 这类 workspace 相对路径) + +### 目录选中后的路径生成(`commandPaletteFileSearchPathForDirectory`) + +workspace root 内目录根据 `usePathPrefix` 决定是否加 `./` 前缀。path 模式目录点击传 `usePathPrefix = true`,保持逐级浏览;cross-directory 结果中的 workspace 内目录传 `usePathPrefix = false`,保持 cross-directory 模式继续按 workspace 相对路径搜索。home 下目录始终用 `~` 前缀,其他用绝对路径。所有目录路径追加 `/`。 + +| 目录位置 | 生成 | +|---|---| +| workspace root 下(path 模式) | `@./Sources/` | +| workspace root 下(cross-directory) | `@Sources/` | +| home 下 | `@~/Develop/` | +| 绝对路径 | `@/opt/homebrew/` | + +### 文件打开(`openFileInDefaultEditor`) + +| 文件类型 | 行为 | +|---|---| +| 脚本 (`.sh`, `.py`, `.rb`, `.pl`, `.php`) | 默认文本编辑器 | +| 已知二进制 (`.executable`, `.unixExecutable`) | Finder 中显示 | +| 无扩展名可执行 + 检测为文本 (UTF-8 可解码, 无 null byte) | 默认文本编辑器 | +| 无扩展名可执行 + 检测为二进制 | Finder 中显示 | +| 软链接 | 解析到目标后按上述规则 | +| 其他 | `NSWorkspace.shared.open` | + +文件打开会先在异步任务中计算打开策略,再回主线程调用 `NSWorkspace`。`isTextFile(at:)` 检测逻辑:读取文件前 4096 字节,包含 `\0`(null byte)→ 二进制,严格 UTF-8 解码成功 → 文本,空文件 → 视为文本。 + +## 搜索流程 + +1. `commandPaletteQuery` 变化 → `onChange` 触发 `scheduleCommandPaletteResultsRefresh` +2. scope 变更时清零 `commandPaletteNucleoSearchIndex`(清除旧 index) +3. **Path 模式**:`commandPaletteFileSearchPathEntries` → `listFilesInDirectory` 单层列表 → nucleo 搜索 +4. **Cross-directory 模式**:`searchCrossDirectory` 并行 BFS + 即时打分 + fast quit,结果已排序直接构建 entries,不走 nucleo pipeline + +### Cross-directory 去重 + +两个 `ContentView` 实例可能收到同一 query 变更。`fileSearchDedupFingerprint` 使用 query + workspace root 确保同一 workspace 的同一 query 只触发一次后台搜索,同时允许不同 workspace 里的同名 query 各自刷新。离开 cross-directory 模式时会清空 token,避免 path 模式结果污染下一次 fuzzy 搜索。 + +### Cross-directory 取消 + +`commandPaletteSearchTask` 持有搜索生命周期;新搜索会 cancel 旧 task。`searchCrossDirectory` 和每个 child task 通过 `Task.isCancelled` 退出,避免全局 generation side channel。 + +## 关键函数索引 + +纯 Quick Open 搜索/打开逻辑在 `Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/QuickOpen/` 中;`Sources/CommandPaletteQuickOpenFileSearch.swift` 保留 app 侧 `ContentView` wrapper;palette 状态和条目装配仍在 `Sources/ContentView.swift`。 + +| 函数 | 类型 | 职责 | +|---|---|---| +| `commandPaletteListScope(for:)` | static | 根据 query 前缀判定 scope | +| `commandPaletteQueryForMatching` | static | 提取搜索词(去掉 scope 前缀) | +| `commandPaletteFileSearchFingerprint` | instance | 条目指纹(hash query) | +| `commandPaletteFileSearchPathEntries` | instance | 生成 path 模式条目列表 | +| `resolvedFileSearchWorkspaceRoot` | instance | 获取 workspace root 目录 | +| `commandPaletteFileSearchResolve` | static | 解析 query → (dir, searchTerm, isPathMode) | +| `commandPaletteFileSearchMatchingTerm` | static | 从 matching query 提取搜索词(去掉路径前缀) | +| `resolveLongestExistingDirectory` | static | 找出最长存在的目录前缀 | +| `searchCrossDirectory` | static | 并行 BFS + 即时打分 + fast quit | +| `fileSearchCrossDirectoryFuzzyScore` | static | cross-directory 专用 fuzzy score | +| `fileSearchCrossDirectoryFuzzyMatch` | static | cross-directory 专用 fuzzy match,返回 score 和高亮 indices | +| `insertTopK` | static | min-heap 插入,维护 top-K | +| `ScoredFile` | struct | 即时打分的返回结构 | +| `commandPaletteFileSearchDotEntry` | static | 创建 `.` 条目 | +| `commandPaletteFileSearchPathForDirectory` | static | 目录路径 → 新 query 路径 | +| `openFileInDefaultEditor` | static | 文件打开安全分发(脚本→编辑器,二进制→Finder) | +| `isTextFile` | static | 检测文件是否为文本 | +| `listFilesInDirectory` | static | 单层目录列表(path 模式) | +| `shouldSkipDirectoryForQuickOpen` | static | 判断是否跳过目录 | + +## 已知修复记录 + +| # | 问题 | 根因 | 修复 | +|---|---|---|---| +| 1 | `@/` 不更新搜索结果 | `commandPaletteQueryForMatching` 取 "last slash 之后" 导致 `/` 匹配为空 → 回退到 workspace root | 改为返回完整 suffix,由 `commandPaletteFileSearchResolve` 统一解析 | +| 2 | `Cmd+P` 后输入 `@` 不切换 scope | scope 切换时旧的 nucleo index(workspace 条目)未清除 | scope 变更时清零 `commandPaletteNucleoSearchIndex` | +| 3 | fileSearch 内 `@`→`@/` 不刷新结果 | `hasVisibleResultsForScope` 为 true 阻塞了同步 seeding | `.fileSearch` 始终允许同步 seeding | +| 4 | `@/` 只显示 4 个目录 | `.skipsHiddenFiles` 跳过了 macOS 根目录下的系统隐藏目录 | `listFilesInDirectory` 移除 `.skipsHiddenFiles` | +| 5 | Settings 中不显示 Quick Open | 只加到 `KeyboardShortcutSettings.Action`,未加到 `ShortcutAction`(Settings UI 用) | 同步添加 `ShortcutAction.quickOpenFile` + `displayName` + `group` + `defaultStroke` | +| 6 | `@/b`、`@~/b` 搜索结果为空 | matching query 包含路径字符(`/`、`~`)无法匹配文件名 | 新增 `commandPaletteFileSearchMatchingTerm`,path 模式下仅取 last `/` 之后作为搜索词传入 nucleo | +| 7 | `@~` 不显示 home 目录 | `resolveLongestExistingDirectory` double-insert 了路径开头的空字符串,导致 `//Users/...` | 移除冗余的 `components.insert("", at: 0)` | +| 8 | Shell 脚本/二进制文件意外执行 | `NSWorkspace.shared.open` 对可执行文件走终端 | `openFileInDefaultEditor` 按类型分发:脚本→文本编辑器,二进制→Finder,无扩展名可执行→检测 text/binary | +| 9 | 目录选中后 query 未加 `/`;模式切换不一致 | 路径生成无后缀;path 触发规则不明确 | 追加 `/`;path 前缀限定为 `~`、`/`、`./` | +| 10 | workspace 内目录用了 `~/` 而非 `./` | home dir 检查优先于 workspace root | 调换检查顺序:workspace root → home dir | +| 11 | `@yun` 无结果;`@desktop` 找不到深层文件 | DFS 在大目录耗尽 maxCount;fast quit 过早跳过深层目录 | 改为 **BFS**;移除 fast quit 仅用于深度遍历场景 | +| 12 | path 模式 + 搜索词走向递归搜索 | 与 path 模式语义不符(path 应逐级搜索) | path 模式始终用 `listFilesInDirectory` 单层;cross-directory 才递归 | +| 13 | `@/.t` 命中 `.VolumeIcon.icns` 等不相关文件 | nucleo FFI 对不匹配条目返回 score=0 但不排除;Swift fallback 返回 nil 排除 | `.fileSearch` 结果 `filter { $0.score > 0 }`,不修改 `CommandPaletteNucleoSearch` | +| 14 | cross-directory 打字卡顿 | DFS enumerator 同步主线程 I/O + 遍历后 nucleo 搜索 | 改为 `searchCrossDirectory` 并行 BFS + cross-directory 专用 fuzzy score 即时打分 + fast quit | +| 15 | `@cry/gnu` 搜不到 `crypto/gnupg.md`,或 `@cry/to` 误命中 `crypto` | cross-directory 没有按 workspace 相对路径和 raw query 统一匹配 | cross-directory 使用 `fileSearchCrossDirectoryFuzzyMatch`,`/` 作为 query 字符参与匹配,score 和高亮共用同一规则 | +| 16 | cross-directory 中选中 workspace 内目录后切到 path 模式 | 目录 action 生成了带 `./` 前缀的 query | cross-directory 目录 action 生成无 `./` 的 workspace 相对 query,保持 cross-directory 模式 | + +## 单元测试(47 个) + +测试类 `QuickOpenFileSearchScopeTests`,位于 `cmuxTests/CommandPaletteQuickOpenFileSearchTests.swift`。 + +**Scope 判定(4 个):** +- `testCommandsScopeWithPrefix` — `>` → `.commands` +- `testFileSearchScopeWithPrefix` — `@`、`@/`、`@~`、`@main` → `.fileSearch` +- `testSwitcherScopeWithoutPrefix` — 无前缀 → `.switcher` +- `testCommandsPrefixTakesPriorityOverFileSearch` — `>@` → `.commands`(`>` 优先) + +**去重指纹(3 个):** +- `testFileSearchPathModeDoesNotUseDedupFingerprint` — path 模式不使用 cross-directory 去重 +- `testFileSearchCrossDirectoryUsesEffectiveQueryAndWorkspaceForDedupFingerprint` — query + workspace root 共同决定去重 +- `testNonFileSearchDoesNotUseFileSearchDedupFingerprint` — 非 file search 不使用该指纹 + +**Query 提取(5 个):** +- `testFileSearchMatchingQueryEmpty` — `@`、`@␠` → `""` +- `testFileSearchMatchingQueryRootSlash` — `@/` → `"/"` +- `testFileSearchMatchingQueryHome` — `@~` → `"~"` +- `testFileSearchMatchingQueryPathWithSearch` — `@/Users/cha` → `"/Users/cha"` +- `testFileSearchMatchingQueryCrossDirectory` — `@main.swift` → `"main.swift"` + +**Nucleo 搜索词提取(8 个):** +- `testMatchingTermEmpty` — `""` → `""` +- `testMatchingTermCrossDirectory` — `"main.swift"` → `"main.swift"` +- `testMatchingTermPathOnly` — `"/"` → `""`, `"~"` → `""` +- `testMatchingTermNoPrefixIsCrossDirectory` — 无前缀 → 保持全文 +- `testMatchingTermPathWithSearch` — `"/b"` → `"b"`, `"~/Develop"` → `"Develop"` +- `testMatchingTermDotSlashUsesWorkspaceRoot` — `./` 目录判断使用 workspace root +- `testMatchingTermDirectoryWithoutTrailingSlash` — `/tmp` 存在 → `""`, `/tmp/nonexistent` 不存在 → 搜索词 +- `testMatchingTermDotfileSearch` — `/.t` → `.t`,`./.git` 像普通 dot path 一样按文件/目录类型处理 + +**路径解析(11 个):** +- `testResolveEmptyQueryReturnsWorkspaceRoot` — 空 → workspace root +- `testResolveWhitespaceOnlyReturnsWorkspaceRoot` — 纯空白 → workspace root +- `testResolveHomePrefix` — `~` → home dir +- `testResolveHomeSubdirectory` — `~/Develop` → 以 home 开头 +- `testResolveRootSlash` — `/` → `"/"` +- `testResolveCrossDirectoryMode` — `main.swift` → cross-directory +- `testResolveCrossDirectoryModeMultipleWords` — `"content view"` → cross-directory +- `testResolveNilWorkspaceRootFallsBackToHome` — nil root → home +- `testResolveDotSlashEntersPathMode` — `./Sources/` → path mode +- `testResolveDotSlashWithSearch` — `./Sources/Cont` → path mode +- `testResolveNoPrefixIsCrossDirectory` — `"ab"` → cross-directory + +**最长目录前缀解析(4 个):** +- `testResolveExistingRootDirectory` — `/` → `("/", "")` +- `testResolveExistingHomeDirectory` — home path → `(home, "")` +- `testResolveExistingDirectoryWithRemainder` — `home + "/nonexistent"` → `(home, "nonexistent")` +- `testResolveCompletelyNonexistentPath` — `/xyz/foo/bar` → `("/", remainder)` + +**路径格式化(5 个):** +- `testPathForHomeSubdirectory` — home 下 → `"~/"` 前缀 +- `testPathForHomeDirectoryItself` — home 本身 → `"~/"` +- `testPathUnderWorkspaceRoot` — root 下 → 相对路径 +- `testPathUnderWorkspaceInsideHome` — root 在 home 内 → 相对路径 +- `testPathOutsideWorkspaceAndHome` — `/opt` → 绝对路径 + +**目录跳过(2 个):** +- `testKnownSkipDirectories` — `node_modules`, `.build` 等被跳过 +- `testNormalDirectoriesNotSkipped` — `Sources`, `lib` 等正常 + +**Cross-directory raw fuzzy(5 个):** +- `testCrossDirectorySlashQueryMatchesRelativePath` — `cry/gnu` → `crypto/gnupg.md` +- `testFileSearchCrossDirectoryFuzzyMatchKeepsSlashInQuery` — `/` 保留为 query 字符,并返回高亮 indices +- `testCrossDirectorySlashQueryDoesNotMatchInsideSinglePathComponent` — `cry/to` 不误命中 `crypto/gnupg.md` +- `testSearchCrossDirectoryDeduplicatesSymlinkDirectoryCycles` — symlink 回环不会重复扫描同一真实目录 +- `testQuickOpenRelativePathUsesWorkspaceRoot` — cross-directory target 使用 workspace 相对路径 diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index a35edce653a7..9cf09f02de6a 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,13 +1,84 @@ { - "originHash" : "65983485b9d028d53e4056dd7498235f15e29870eef8c38c144648d54a291b63", "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "db806756c989760b35108146381535aec231092b", + "version" : "4.7.0" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, + { + "identity" : "posthog-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PostHog/posthog-ios.git", + "state" : { + "revision" : "179438b8e0c9a357ab0caf453577f766ffddbcb3", + "version" : "3.59.3" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa.git", + "state" : { + "revision" : "e6d00b6171c0c055debdf278554db035ae958926", + "version" : "9.16.1" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" + "revision" : "9f542610331815e29cc3821d3b6f488db8715517", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" } }, { @@ -18,7 +89,34 @@ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", "version" : "3.15.1" } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "621eca8d091cc110a99adc23bb0d6618a65b4544", + "version" : "9.13.0" + } } ], - "version" : 3 + "version" : 2 }