diff --git a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift index c88ed707e0..9aea16c300 100644 --- a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift @@ -122,7 +122,7 @@ final class KeyboardShortcutFeatureModel: ObservableObject { guard Set(bindings).count == bindings.count else { throw KeyboardShortcutUpdateError.duplicateBinding } - if let owner = conflictingCommand(for: bindings, excluding: commandID) { + if let owner = try conflictingCommand(for: bindings, excluding: commandID) { throw KeyboardShortcutUpdateError.conflict(commandID: owner.id) } @@ -133,13 +133,14 @@ final class KeyboardShortcutFeatureModel: ObservableObject { /// Validate a batch against its final registrations, allowing swaps without transient conflicts. func validateOverrides(_ proposed: [String: [KeyboardShortcutBinding]]) throws { - let final = registrations(for: proposed) for (id, bindings) in proposed { guard LitheCommandCatalog.command(id: id) != nil else { throw KeyboardShortcutUpdateError.unknownCommand(id) } guard bindings.allSatisfy(\.isAssignable), Set(bindings).count == bindings.count else { throw KeyboardShortcutUpdateError.invalidBinding } - guard bindings != effectiveBindings(for: id) else { continue } - if let owner = final.first(where: { $0.commandID != id && !Set(bindings).isDisjoint(with: $0.bindings) }) { - throw KeyboardShortcutUpdateError.conflict(commandID: owner.commandID) + } + let conflicts = try finalConflicts(overrides: proposed) + for id in proposed.keys.sorted() where proposed[id] != effectiveBindings(for: id) { + if let owner = conflictOwner(of: id, in: conflicts) { + throw KeyboardShortcutUpdateError.conflict(commandID: owner) } } } @@ -169,15 +170,34 @@ final class KeyboardShortcutFeatureModel: ObservableObject { recordingCommandID = nil } + /// The first command, in catalog order, that already owns one of `bindings`. func conflictingCommand( for bindings: [KeyboardShortcutBinding], excluding excludedCommandID: String - ) -> LitheCommandDefinition? { - let candidates = Set(bindings) - guard !candidates.isEmpty else { return nil } - return commands.first { command in - command.id != excludedCommandID - && !candidates.isDisjoint(with: effectiveBindings(for: command.id)) + ) throws -> LitheCommandDefinition? { + guard !bindings.isEmpty else { return nil } + var overrides = settings.keyboardShortcutOverrides + overrides[excludedCommandID] = bindings + let conflicts = try finalConflicts(overrides: overrides) + return conflictOwner(of: excludedCommandID, in: conflicts).flatMap(LitheCommandCatalog.command(id:)) + } + + /// Core decides what is in conflict among the final preset-plus-override bindings. + private func finalConflicts( + overrides: [String: [KeyboardShortcutBinding]] + ) throws -> [KeymapBindingConflict] { + let base = commands.map { + KeyboardShortcutRegistration(commandID: $0.id, bindings: settings.keyboardShortcutPreset.bindings(for: $0)) } + switch RustCoreBridge().analyzeKeymapConflicts(base: base, overrides: overrides) { + case .success(let conflicts): return conflicts + case .failure: throw KeyboardShortcutUpdateError.invalidBinding + } + } + + private func conflictOwner(of commandID: String, in conflicts: [KeymapBindingConflict]) -> String? { + let others = Set(conflicts.filter { $0.commands.contains(commandID) }.flatMap(\.commands)) + .subtracting([commandID]) + return commands.first { others.contains($0.id) }?.id } } diff --git a/macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift b/macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift new file mode 100644 index 0000000000..ac07df1812 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift @@ -0,0 +1,54 @@ +import Foundation + +/// A key sequence and `when` clause that several commands are bound to at once, as decided by Core. +struct KeymapBindingConflict: Decodable, Equatable, Sendable { + let key: String + let commands: [String] +} + +extension KeyboardShortcutBinding { + /// Native key to Core binding grammar. The AppKit key names stay here; what the + /// resulting text means (aliases, modifier order, equivalence) is Core's. + var coreBindingText: String { + switch self { + case let .keyPress(key, modifiers): + let names = [ + modifiers.contains(.control) ? "ctrl" : nil, + modifiers.contains(.option) ? "alt" : nil, + modifiers.contains(.shift) ? "shift" : nil, + modifiers.contains(.command) ? "meta" : nil + ].compactMap { $0 } + // macOS calls the Backspace key "delete". + return (names + [key == "delete" ? "backspace" : key]).joined(separator: "+") + case .doubleTap(.shift): + return "doubleshift" + } + } +} + +extension RustCoreBridge { + /// Conflicts among the final bindings after `overrides` replace `base` per command. + /// An empty override unbinds the command. Failure is returned, never treated as "no conflict". + func analyzeKeymapConflicts( + base: [KeyboardShortcutRegistration], + overrides: [String: [KeyboardShortcutBinding]] + ) -> Result<[KeymapBindingConflict], CoreCallError> { + struct Entry: Encodable { let command: String; let key: String } + struct Layer: Encodable { let entries: [Entry]; let disabledCommands: [String] } + struct Request: Encodable { let platform = "macos"; let layers: [Layer] } + struct Response: Decodable { let conflicts: [KeymapBindingConflict] } + func entries(_ commandID: String, _ bindings: [KeyboardShortcutBinding]) -> [Entry] { + bindings.map { Entry(command: commandID, key: $0.coreBindingText) } + } + let request = Request(layers: [ + Layer(entries: base.flatMap { entries($0.commandID, $0.bindings) }, disabledCommands: []), + Layer( + entries: overrides.sorted { $0.key < $1.key }.flatMap { entries($0.key, $0.value) }, + disabledCommands: overrides.filter { $0.value.isEmpty }.map(\.key).sorted() + ) + ]) + let result: Result = executeResult( + command: "keymap.analyzeConflicts", payload: request) + return result.map(\.conflicts) + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustTerminalLinks.swift b/macos/Sources/Lithe/Core/Rust/RustTerminalLinks.swift new file mode 100644 index 0000000000..3b13543485 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustTerminalLinks.swift @@ -0,0 +1,54 @@ +import Foundation + +struct TerminalLinkLocation: Equatable, Sendable { + let url: URL + let line: Int? + let column: Int? +} + +enum TerminalLinkTarget: Equatable, Sendable { case file(TerminalLinkLocation); case external(URL) } + +extension RustCoreBridge { + /// Core interprets the link text (location suffix, `file://`, `~`, relative paths). + /// Whether the file exists is a native fact, checked here after Core has named the path. + func terminalLinkTarget( + _ rawLink: String, + relativeTo directory: URL, + homeDirectory: URL, + fileExists: (URL) -> Bool + ) -> TerminalLinkTarget? { + struct Request: Encodable { + let texts: [String] + let baseDirectory: String + let homeDirectory: String + } + struct Target: Decodable { + let kind: String + let path: String? + let line: Int? + let column: Int? + let url: String? + } + struct Response: Decodable { let targets: [Target?] } + let result: Result = executeResult( + command: "terminal.parseLinkTarget", + payload: Request( + texts: [rawLink], + baseDirectory: directory.path, + homeDirectory: homeDirectory.path + ) + ) + guard case .success(let response) = result, let target = response.targets.first ?? nil else { return nil } + switch target.kind { + case "external": + return target.url.flatMap(URL.init(string:)).map(TerminalLinkTarget.external) + case "file": + guard let path = target.path else { return nil } + let url = URL(fileURLWithPath: path) + guard fileExists(url) else { return nil } + return .file(TerminalLinkLocation(url: url, line: target.line, column: target.column)) + default: + return nil + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 5ea5ca0d3d..e60a3f28e1 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -232,9 +232,10 @@ extension AppModel { private func openTerminalLink(_ link: String, params: [String: String], sessionID: UUID) { guard let session = terminalSessions.first(where: { $0.id == sessionID }), let fallbackDirectory = session.currentDirectory ?? workspaceURL else { return } - guard let target = TerminalLinkResolver.resolve( + guard let target = RustCoreBridge().terminalLinkTarget( link, relativeTo: fallbackDirectory, + homeDirectory: services.fileStorage.homeDirectory(), fileExists: { [services] in services.fileStorage.fileExists(at: $0) } ) else { return } switch target { diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift index ff9e1fa94a..c054668580 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift @@ -306,6 +306,9 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { } /// Recognizes two standalone Shift taps while rejecting Shift-modified typing. +/// It runs on every native key event, so it stays out of Core (no per-key IPC). Windows keeps the +/// same machine in double-shift-recognizer.ts; both are tested against +/// shared/fixtures/keymap/modifier-gesture-v1.json and must change together. struct DoubleShiftGestureRecognizer { let threshold: TimeInterval private(set) var shiftWasDown = false diff --git a/macos/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift b/macos/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift deleted file mode 100644 index 1c509e5554..0000000000 --- a/macos/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -public struct TerminalLinkLocation: Equatable, Sendable { - public let url: URL - public let line: Int? - public let column: Int? - public init(url: URL, line: Int?, column: Int?) { self.url = url; self.line = line; self.column = column } -} - -public enum TerminalLinkTarget: Equatable, Sendable { case file(TerminalLinkLocation); case external(URL) } - -public enum TerminalLinkResolver { - public static func resolve( - _ rawLink: String, - relativeTo directory: URL, - fileExists: (URL) -> Bool - ) -> TerminalLinkTarget? { - let rawLink = rawLink.trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawLink.isEmpty else { return nil } - if let url = URL(string: rawLink), let scheme = url.scheme, !scheme.isEmpty, !url.isFileURL { - return .external(url) - } - let (link, line, column) = splitLocationSuffix(rawLink) - guard !link.isEmpty else { return nil } - let path = URL(string: link)?.isFileURL == true - ? URL(string: link)!.path - : (link as NSString).expandingTildeInPath - let url = path.hasPrefix("/") - ? URL(fileURLWithPath: path).standardizedFileURL - : directory.appendingPathComponent(path).standardizedFileURL - guard fileExists(url) else { return nil } - return .file(TerminalLinkLocation(url: url, line: line, column: column)) - } - - private static func splitLocationSuffix(_ value: String) -> (String, Int?, Int?) { - var components = value.split(separator: ":", omittingEmptySubsequences: false).map(String.init) - var line: Int?; var column: Int? - if components.count >= 3, let c = Int(components.last!), let l = Int(components[components.count - 2]) { - column = c; line = l; components.removeLast(2) - } else if components.count >= 2, let l = Int(components.last!) { - line = l; components.removeLast() - } - return (components.joined(separator: ":"), line, column) - } -} diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index f3a416b7c2..f21bddf155 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -249,22 +249,6 @@ struct TerminalModuleTests { feature.stopAllSessions() } - @Test - func linkResolverKeepsExternalURLsAndResolvesLocations() { - let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-module-test") - let expected = workspace.appendingPathComponent("Sources/App.swift").standardizedFileURL - #expect(TerminalLinkResolver.resolve( - "Sources/App.swift:12:4", - relativeTo: workspace, - fileExists: { $0 == expected } - ) == .file(TerminalLinkLocation(url: expected, line: 12, column: 4))) - #expect(TerminalLinkResolver.resolve( - "https://example.com", - relativeTo: workspace, - fileExists: { _ in false } - ) == .external(URL(string: "https://example.com")!)) - } - @Test func processOutputIsForwardedBeforeAndAfterProcessStart() throws { let transport = TestTransport() diff --git a/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift b/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift new file mode 100644 index 0000000000..7ef37b8fa6 --- /dev/null +++ b/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import Lithe + +/// The fixtures under shared/fixtures are the contract both hosts are tested against. +struct KeymapTerminalCoreFixtureTests { + private static func fixture(_ name: String) throws -> [String: Any] { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/\(name)")) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test + func doubleShiftRecognizerMatchesTheSharedGestureFixture() throws { + let fixture = try Self.fixture("keymap/modifier-gesture-v1.json") + let threshold = try #require(fixture["thresholdSeconds"] as? Double) + for sequence in try #require(fixture["sequences"] as? [[String: Any]]) { + let name = sequence["name"] as? String ?? "" + var recognizer = DoubleShiftGestureRecognizer(threshold: threshold) + for (index, step) in (sequence["steps"] as? [[String: Any]] ?? []).enumerated() { + switch step["event"] as? String { + case "keyDown": recognizer.handleKeyDown() + case "reset": recognizer.reset() + default: + let triggered = recognizer.handleFlagsChanged( + isShiftDown: step["shift"] as? Bool ?? false, + hasOtherModifiers: step["otherModifiers"] as? Bool ?? false, + timestamp: step["timestamp"] as? Double ?? 0 + ) + #expect(triggered == (step["expected"] as? Bool), "\(name) step \(index)") + } + } + } + } + + @Test + func terminalLinksResolveThroughCoreAndCheckExistenceNatively() throws { + let fixture = try Self.fixture("terminal/link-target-v1.json") + let cases = try #require(fixture["cases"] as? [[String: Any]]) + let workspace = URL(fileURLWithPath: "/work/project") + let relative = try #require(cases.first { $0["name"] as? String == "relative-path-with-line-and-column" }) + let expected = try #require((relative["expected"] as? [[String: Any]])?.first) + let bridge = RustCoreBridge() + + let target = bridge.terminalLinkTarget( + "Sources/App.swift:12:4", + relativeTo: workspace, homeDirectory: URL(fileURLWithPath: "/Users/me"), + fileExists: { $0.path == expected["path"] as? String } + ) + #expect(target == .file(TerminalLinkLocation( + url: URL(fileURLWithPath: "/work/project/Sources/App.swift"), line: 12, column: 4))) + #expect(bridge.terminalLinkTarget("Sources/App.swift:12:4", relativeTo: workspace, homeDirectory: URL(fileURLWithPath: "/Users/me"), fileExists: { _ in false }) == nil) + // A location suffix must not be mistaken for a URL scheme. + #expect(bridge.terminalLinkTarget("App.swift:12", relativeTo: workspace, homeDirectory: URL(fileURLWithPath: "/Users/me"), fileExists: { _ in true }) + == .file(TerminalLinkLocation(url: workspace.appendingPathComponent("App.swift"), line: 12, column: nil))) + #expect(bridge.terminalLinkTarget("https://example.com/docs", relativeTo: workspace, homeDirectory: URL(fileURLWithPath: "/Users/me"), fileExists: { _ in false }) + == .external(URL(string: "https://example.com/docs")!)) + } + + private struct FixtureEntry: Codable { let command: String; let key: String; let when: String?; let enabled: Bool? } + private struct FixtureLayer: Codable { let entries: [FixtureEntry]?; let disabledCommands: [String]? } + private struct FixtureConflict: Codable, Equatable { let key: String; let when: String?; let commands: [String] } + private struct FixtureExpected: Codable { let conflicts: [FixtureConflict] } + private struct FixtureCase: Codable { let name: String; let platform: String; let layers: [FixtureLayer]; let expected: FixtureExpected } + private struct FixtureFile: Codable { let cases: [FixtureCase] } + private struct ConflictRequest: Encodable { let platform: String; let layers: [FixtureLayer] } + private struct ConflictResponse: Decodable { let conflicts: [FixtureConflict] } + + @Test + func keymapConflictsMatchTheSharedFixture() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/keymap/binding-conflicts-v1.json")) + for item in try JSONDecoder().decode(FixtureFile.self, from: data).cases { + let result: Result = RustCoreBridge().executeResult( + command: "keymap.analyzeConflicts", + payload: ConflictRequest(platform: item.platform, layers: item.layers)) + #expect(try result.get().conflicts == item.expected.conflicts, "\(item.name)") + } + } + + @Test + func nativeBindingsReachCoreInTheSameGrammarAsStoredText() { + let binding = KeyboardShortcutBinding.keyPress(key: "p", modifiers: [.command, .shift]) + #expect(binding.coreBindingText == "shift+meta+p") + #expect(KeyboardShortcutBinding.keyPress(key: "delete", modifiers: [.command]).coreBindingText == "meta+backspace") + #expect(KeyboardShortcutBinding.doubleTap(.shift).coreBindingText == "doubleshift") + } +} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index ac288233cb..fdcc86a56c 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2104,118 +2104,6 @@ struct LitheCoreLogicTests { #expect(index.characterOffset(forLine: 2) == 9) } - @Test - func doubleShiftRecognizerRequiresTwoStandaloneTaps() { - var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) - - var triggered = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.00 - ) - #expect(!triggered) - triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.05 - ) - #expect(!triggered) - triggered = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.20 - ) - #expect(!triggered) - triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.25 - ) - #expect(triggered) - } - - @Test - func doubleShiftRecognizerRejectsUppercaseTypingAndInterveningKeys() { - var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) - - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.00 - ) - recognizer.handleKeyDown() - var triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.05 - ) - #expect(!triggered) - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.20 - ) - recognizer.handleKeyDown() - triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.25 - ) - #expect(!triggered) - - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 2.00 - ) - _ = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 2.05 - ) - recognizer.handleKeyDown() - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 2.20 - ) - triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 2.25 - ) - #expect(!triggered) - } - - @Test - func resettingDoubleShiftRecognizerDropsPendingTap() { - var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) - - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.00 - ) - _ = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.05 - ) - recognizer.reset() - _ = recognizer.handleFlagsChanged( - isShiftDown: true, - hasOtherModifiers: false, - timestamp: 1.20 - ) - let triggered = recognizer.handleFlagsChanged( - isShiftDown: false, - hasOtherModifiers: false, - timestamp: 1.25 - ) - - #expect(!triggered) - } - @Test func markdownImageInsertionSeparatesTheReferenceFromRawHTML() { let source = "\n
\n" @@ -3045,40 +2933,6 @@ struct EditorDocumentTests { #expect(transport.stopCount == 1) } - @Test - func terminalLinkResolverResolvesWorkspacePathAndLocation() { - let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-link-tests") - let sourceURL = workspace.appendingPathComponent("Sources/App.swift") - let fileManager = FileManager.default - try? fileManager.createDirectory( - at: sourceURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - defer { try? fileManager.removeItem(at: workspace) } - fileManager.createFile(atPath: sourceURL.path, contents: Data()) - - let target = TerminalLinkResolver.resolve( - "Sources/App.swift:12:4", - relativeTo: workspace, - fileExists: { fileManager.fileExists(atPath: $0.path) } - ) - - #expect(target == .file( - TerminalLinkLocation(url: sourceURL.standardizedFileURL, line: 12, column: 4) - )) - } - - @Test - func terminalLinkResolverKeepsExternalURLsAsExternalTargets() { - let target = TerminalLinkResolver.resolve( - "https://example.com/docs", - relativeTo: URL(fileURLWithPath: "/tmp"), - fileExists: { _ in false } - ) - - #expect(target == .external(URL(string: "https://example.com/docs")!)) - } - @Test @MainActor func terminalSessionPublishesTitleDirectoryAndExitState() { diff --git a/rust/lithe-core/src/keymap/bindings.rs b/rust/lithe-core/src/keymap/bindings.rs new file mode 100644 index 0000000000..ab1d4a2db2 --- /dev/null +++ b/rust/lithe-core/src/keymap/bindings.rs @@ -0,0 +1,293 @@ +//! Shortcut binding grammar and effective-binding conflict analysis. +//! +//! Hosts keep native key-event conversion and the synchronous key-matching path. +//! Core owns what a binding string means, which bindings are in force after +//! defaults, presets and user overrides are layered, and which of them collide. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const MAX_ENTRIES: usize = 20_000; +const MAX_BINDING_BYTES: usize = 256; +const MAX_STEPS: usize = 8; + +/// Host operating system whose command key alias (`cmd`/`mod`) is being parsed. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BindingPlatform { + /// `cmd` is the Command key. + Macos, + /// `cmd` is folded into Control, as the Windows host always did. + Windows, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +enum Modifier { + Ctrl, + Alt, + Shift, + Meta, +} + +/// One keystroke of a binding: a canonical modifier set plus a lower-case key name. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct BindingStep { + modifiers: Vec, + key: String, +} + +impl BindingStep { + fn canonical(&self) -> String { + let mut text = String::new(); + for modifier in &self.modifiers { + text.push_str(match modifier { + Modifier::Ctrl => "ctrl+", + Modifier::Alt => "alt+", + Modifier::Shift => "shift+", + Modifier::Meta => "meta+", + }); + } + text + &self.key + } +} + +fn modifier_alias(token: &str, platform: BindingPlatform) -> Option { + match token { + "ctrl" | "control" => Some(Modifier::Ctrl), + "alt" | "option" | "opt" => Some(Modifier::Alt), + "shift" => Some(Modifier::Shift), + "cmd" | "command" | "mod" => Some(match platform { + BindingPlatform::Macos => Modifier::Meta, + BindingPlatform::Windows => Modifier::Ctrl, + }), + "meta" | "super" | "win" => Some(Modifier::Meta), + _ => None, + } +} + +fn key_alias(token: &str) -> String { + match token { + "return" => "enter", + "esc" => "escape", + "del" => "delete", + "pgup" => "pageup", + "pgdn" => "pagedown", + "spacebar" => "space", + other => other, + } + .to_string() +} + +fn parse_step(text: &str, platform: BindingPlatform) -> Result { + // A trailing plus is the `+` key itself (`ctrl++`), not an empty segment. + let (head, plus_key) = match text.strip_suffix('+') { + Some(rest) if rest.is_empty() => ("", true), + Some(rest) => match rest.strip_suffix('+') { + Some(head) => (head, true), + None => return Err("empty_key"), + }, + None => (text, false), + }; + let mut modifiers = Vec::new(); + let mut key: Option = plus_key.then(|| "+".to_string()); + for token in head.split('+').filter(|token| !token.is_empty()) { + let token = token.to_lowercase(); + if let Some(modifier) = modifier_alias(&token, platform) { + if !modifiers.contains(&modifier) { + modifiers.push(modifier); + } + } else if key.replace(key_alias(&token)).is_some() { + return Err("multiple_keys"); + } + } + modifiers.sort(); + Ok(BindingStep { + modifiers, + key: key.ok_or("empty_key")?, + }) +} + +/// Parses one binding string such as `cmd+shift+p` or `ctrl+k ctrl+t` into canonical steps. +/// +/// Modifier order, case and aliases (`cmd`, `command`, `mod`, `option`, `esc`, ...) do not +/// affect the result, so equivalent spellings compare equal. Errors are stable codes. +pub fn parse_binding( + binding: &str, + platform: BindingPlatform, +) -> Result, &'static str> { + if binding.len() > MAX_BINDING_BYTES { + return Err("too_long"); + } + let steps = binding + .split_whitespace() + .map(|step| parse_step(step, platform)) + .collect::, _>>()?; + match steps.len() { + 0 => Err("empty"), + count if count > MAX_STEPS => Err("too_many_steps"), + _ => Ok(steps), + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One binding contributed by a layer. `enabled: false` removes the command's bindings. +pub struct BindingEntry { + pub command: String, + pub key: String, + #[serde(default)] + pub when: Option, + #[serde(default)] + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// A source of bindings. Within a layer, a command's entries replace everything below it. +pub struct BindingLayer { + #[serde(default)] + pub entries: Vec, + /// Commands this layer unbinds entirely. + #[serde(default)] + pub disabled_commands: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Layers ordered from lowest priority (defaults) to highest (user overrides). +pub struct AnalyzeConflictsRequest { + pub platform: BindingPlatform, + pub layers: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// Several commands that are active on the same canonical key sequence and `when` clause. +pub struct BindingConflict { + pub key: String, + pub when: Option, + pub commands: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// A binding that is in force but cannot be parsed; it takes no part in conflicts. +pub struct InvalidBinding { + pub command: String, + pub key: String, + pub code: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Conflicts among the final effective bindings, in a deterministic order. +pub struct AnalyzeConflictsResponse { + pub conflicts: Vec, + pub invalid: Vec, +} + +fn normalize_when(when: &Option) -> Option { + let joined = when + .as_deref()? + .split_whitespace() + .collect::>() + .join(" "); + (!joined.is_empty()).then_some(joined) +} + +/// Layers bindings per command and reports every collision among the result. +pub fn analyze_conflicts( + request: AnalyzeConflictsRequest, +) -> Result { + let total: usize = request + .layers + .iter() + .map(|layer| layer.entries.len() + layer.disabled_commands.len()) + .sum(); + if total > MAX_ENTRIES { + return Err(crate::CoreError::new( + crate::ErrorCode::InvalidRequest, + "Too many keymap bindings", + )); + } + // BTreeMap keeps the output independent of the order commands were registered in. + let mut effective: BTreeMap<&str, Vec<&BindingEntry>> = BTreeMap::new(); + for layer in &request.layers { + for command in &layer.disabled_commands { + effective.remove(command.as_str()); + } + let mut replaced: BTreeMap<&str, Vec<&BindingEntry>> = BTreeMap::new(); + for entry in &layer.entries { + replaced + .entry(entry.command.as_str()) + .or_default() + .push(entry); + } + effective.extend(replaced); + } + let mut groups: BTreeMap<(String, Option), Vec> = BTreeMap::new(); + let mut invalid = Vec::new(); + for (command, entries) in effective { + for entry in entries + .into_iter() + .filter(|entry| entry.enabled != Some(false)) + { + match parse_binding(&entry.key, request.platform) { + Ok(steps) => { + let key = steps + .iter() + .map(BindingStep::canonical) + .collect::>() + .join(" "); + let commands = groups + .entry((key, normalize_when(&entry.when))) + .or_default(); + if !commands.iter().any(|existing| existing == command) { + commands.push(command.to_string()); + } + } + Err(code) => invalid.push(InvalidBinding { + command: command.to_string(), + key: entry.key.clone(), + code, + }), + } + } + } + let conflicts = groups + .into_iter() + .filter(|(_, commands)| commands.len() > 1) + .map(|((key, when), commands)| BindingConflict { + key, + when, + commands, + }) + .collect(); + Ok(AnalyzeConflictsResponse { conflicts, invalid }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equivalent_spellings_share_one_canonical_form() { + let canonical = |text: &str, platform| { + parse_binding(text, platform) + .unwrap() + .iter() + .map(BindingStep::canonical) + .collect::>() + .join(" ") + }; + let windows = BindingPlatform::Windows; + assert_eq!(canonical("Shift+CTRL+P", windows), "ctrl+shift+p"); + assert_eq!(canonical("cmd+shift+p", windows), "ctrl+shift+p"); + assert_eq!(canonical("ctrl++", windows), "ctrl++"); + assert_eq!(canonical("cmd+esc", BindingPlatform::Macos), "meta+escape"); + assert_eq!(parse_binding("ctrl+a+b", windows), Err("multiple_keys")); + assert_eq!(parse_binding("ctrl+", windows), Err("empty_key")); + assert_eq!(parse_binding(" ", windows), Err("empty")); + } +} diff --git a/rust/lithe-core/src/keymap/mod.rs b/rust/lithe-core/src/keymap/mod.rs index 1533d53b6d..e9eec500b3 100644 --- a/rust/lithe-core/src/keymap/mod.rs +++ b/rust/lithe-core/src/keymap/mod.rs @@ -1,5 +1,9 @@ //! Bounded context parsing and Core-owned truth tables for synchronous key dispatch. +mod bindings; + +pub use bindings::{analyze_conflicts, AnalyzeConflictsRequest}; + use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index c1691591b2..4bb62e3644 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -26,6 +26,7 @@ mod project_generators; mod protocol; mod remote; mod runtime; +mod terminal; /// Shared toolchain policy, independent of plugin transport and native discovery. pub mod toolchain; mod vcs; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 0da0bddf46..40aa1c1d00 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -390,6 +390,10 @@ pub enum CoreCommand { KeymapParseContext, /// Evaluates a precompiled shortcut context (`keymap.evaluateContext`). KeymapEvaluateContext, + /// Finds conflicts among the final layered shortcut bindings (`keymap.analyzeConflicts`). + KeymapAnalyzeConflicts, + /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). + TerminalParseLinkTarget, /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). DebugCreateSession, /// Queues a launch or attach request for a debug session (`debug.launch`). @@ -998,6 +1002,8 @@ impl CoreCommand { "markdown.render" => Some(Self::MarkdownRender), "keymap.parseContext" => Some(Self::KeymapParseContext), "keymap.evaluateContext" => Some(Self::KeymapEvaluateContext), + "keymap.analyzeConflicts" => Some(Self::KeymapAnalyzeConflicts), + "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), "debug.javaTestLaunch" => Some(Self::DebugJavaTestLaunch), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 360386c6aa..d7bb150097 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1487,6 +1487,36 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::KeymapAnalyzeConflicts => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid keymap conflict request") + }) + .and_then(crate::keymap::analyze_conflicts); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("conflict result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::TerminalParseLinkTarget => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid terminal link request") + }) + .and_then(crate::terminal::parse_link_targets); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("terminal link result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::MarkdownRender => { match serde_json::from_value::(parsed.payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Markdown render request") diff --git a/rust/lithe-core/src/terminal/link.rs b/rust/lithe-core/src/terminal/link.rs new file mode 100644 index 0000000000..7a255ed467 --- /dev/null +++ b/rust/lithe-core/src/terminal/link.rs @@ -0,0 +1,263 @@ +//! Interprets text that a terminal host recognised as a possible file link. +//! +//! Hosts own hit regions, whether the file exists, and how a target is opened. Core +//! owns what the text means: `path:line:column` suffixes, `file://` decoding, tilde +//! expansion and mapping relative paths onto the working directory. Core never guesses +//! from file extensions; the host's existence check decides whether a link is real. + +use serde::{Deserialize, Serialize}; + +const MAX_TEXTS: usize = 256; +const MAX_TEXT_BYTES: usize = 4096; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Candidate link texts plus the facts needed to resolve them. +pub struct ParseLinkTargetsRequest { + pub texts: Vec, + /// Working directory relative paths are resolved against. + #[serde(default)] + pub base_directory: Option, + /// Home directory used for `~` expansion. + #[serde(default)] + pub home_directory: Option, + /// URI schemes that name host-specific file providers; they are opaque paths, not web links. + #[serde(default)] + pub provider_schemes: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", tag = "kind")] +/// A resolved link. +pub enum LinkTarget { + /// A path to check and open. `explicit` is true when the text itself says it is a path + /// (separator, `~`, `file://`, or a line suffix) rather than a bare word. + #[serde(rename_all = "camelCase")] + File { + path: String, + line: Option, + column: Option, + explicit: bool, + }, + /// A non-file URL for the system to open. + External { url: String }, +} + +#[derive(Debug, Serialize)] +/// One entry per requested text; `None` when the text cannot name a link. +pub struct ParseLinkTargetsResponse { + pub targets: Vec>, +} + +fn scheme_of(text: &str) -> Option<&str> { + let (scheme, rest) = text.split_once(':')?; + let mut chars = scheme.chars(); + let valid = chars.next()?.is_ascii_alphabetic() + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + (valid && rest.starts_with("//")).then_some(scheme) +} + +fn split_location(text: &str) -> (&str, Option, Option) { + let number = |part: &str| { + if part.is_empty() || part.len() > 9 || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + part.parse::().ok().filter(|value| *value > 0) + }; + if let Some((rest, last)) = text.rsplit_once(':') { + if let Some(last) = number(last) { + if let Some((path, line)) = rest.rsplit_once(':') { + if let Some(line) = number(line) { + return (path, Some(line), Some(last)); + } + } + return (rest, Some(last), None); + } + } + (text, None, None) +} + +fn percent_decode(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = text.get(index + 1..index + 3)?; + out.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).ok() +} + +fn is_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} + +/// Decodes `file://[host]/path`; a non-local host becomes a UNC path. +fn decode_file_uri(text: &str) -> Option { + let rest = &text[text.find("//")? + 2..]; + let (host, path) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }; + let path = percent_decode(path)?; + let path = match path.strip_prefix('/') { + Some(stripped) if is_drive_path(stripped) => stripped.to_string(), + _ => path, + }; + if host.is_empty() || host.eq_ignore_ascii_case("localhost") { + Some(path) + } else { + Some(format!( + "\\\\{}{}", + percent_decode(host)?, + path.replace('/', "\\") + )) + } +} + +/// Splits a Windows drive or UNC root from the rest; POSIX paths have no root prefix here. +fn split_root(path: &str) -> Option<(String, &str)> { + if is_drive_path(path) { + return Some((path[..2].to_string(), &path[2..])); + } + let rest = path.strip_prefix("\\\\")?; + let mut parts = rest.splitn(3, ['\\', '/']); + let host = parts.next().unwrap_or(""); + let share = parts.next().unwrap_or(""); + let body = parts.next().unwrap_or(""); + Some((format!("\\\\{host}\\{share}"), body)) +} + +/// Lexically resolves `.` and `..` below `root`, joining with the separator of the style. +fn normalize(root: &str, body: &str, windows_style: bool) -> String { + let separator = if windows_style { "\\" } else { "/" }; + let mut segments: Vec<&str> = Vec::new(); + for segment in body.split(['/', '\\']) { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + other => segments.push(other), + } + } + format!("{root}{separator}{}", segments.join(separator)) +} + +fn is_provider(scheme: &str, request: &ParseLinkTargetsRequest) -> bool { + request + .provider_schemes + .iter() + .any(|known| known.eq_ignore_ascii_case(scheme)) +} + +fn resolve_path(path: &str, request: &ParseLinkTargetsRequest) -> Option { + let expanded; + let mut path = path; + if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { + expanded = format!("{}{}", request.home_directory.as_deref()?, &path[1..]); + path = &expanded; + } + if path.is_empty() { + return None; + } + // A provider URI names a root of its own and is returned as written. + if let Some(scheme) = scheme_of(path) { + return is_provider(scheme, request).then(|| path.to_string()); + } + let base = request + .base_directory + .as_deref() + .filter(|base| !base.is_empty()); + // Under a provider workspace, relative and rooted POSIX paths stay inside the provider. + if let Some(base) = base { + if let Some(scheme) = scheme_of(base).filter(|scheme| is_provider(scheme, request)) { + let after = &base[scheme.len() + 3..]; + let authority_end = after.find('/').unwrap_or(after.len()); + let prefix = &base[..scheme.len() + 3 + authority_end]; + let body = if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{path}", &after[authority_end..]) + }; + return Some(format!("{prefix}{}", normalize("", &body, false))); + } + } + if let Some((root, body)) = split_root(path) { + return Some(normalize(&root, body, true)); + } + if path.starts_with('/') { + return Some(normalize("", path, false)); + } + let base = base?; + let (root, body, windows_style) = match split_root(base) { + Some((root, body)) => (root, body, true), + None => (String::new(), base, base.contains('\\')), + }; + Some(normalize(&root, &format!("{body}/{path}"), windows_style)) +} + +fn parse_one(text: &str, request: &ParseLinkTargetsRequest) -> Option { + let text = text.trim(); + if text.is_empty() || text.len() > MAX_TEXT_BYTES { + return None; + } + let scheme = scheme_of(text); + let is_file_uri = scheme.is_some_and(|scheme| scheme.eq_ignore_ascii_case("file")); + let provider = scheme.is_some_and(|scheme| is_provider(scheme, request)); + let mailto = text + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("mailto:")); + if mailto || (scheme.is_some() && !is_file_uri && !provider) { + return Some(LinkTarget::External { + url: text.to_string(), + }); + } + let (without_location, line, column) = split_location(text); + let decoded; + let raw = if is_file_uri { + decoded = decode_file_uri(without_location)?; + decoded.as_str() + } else { + without_location + }; + let explicit = is_file_uri + || provider + || line.is_some() + || raw.contains(['/', '\\']) + || raw.starts_with('~'); + Some(LinkTarget::File { + path: resolve_path(raw, request)?, + line, + column, + explicit, + }) +} + +/// Resolves every candidate text; the response has the same length and order as the request. +pub fn parse_link_targets( + request: ParseLinkTargetsRequest, +) -> Result { + if request.texts.len() > MAX_TEXTS { + return Err(crate::CoreError::new( + crate::ErrorCode::InvalidRequest, + "Too many terminal link candidates", + )); + } + let targets = request + .texts + .iter() + .map(|text| parse_one(text, &request)) + .collect(); + Ok(ParseLinkTargetsResponse { targets }) +} diff --git a/rust/lithe-core/src/terminal/mod.rs b/rust/lithe-core/src/terminal/mod.rs new file mode 100644 index 0000000000..3013bb1f85 --- /dev/null +++ b/rust/lithe-core/src/terminal/mod.rs @@ -0,0 +1,5 @@ +//! Terminal output interpretation shared by the native hosts. + +mod link; + +pub use link::{parse_link_targets, ParseLinkTargetsRequest}; diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 4baf827331..3f2b966065 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -368,3 +368,51 @@ fn java_test_debug_launch_matches_the_shared_contract_fixture() { ); } } + +fn call_fixture_command(command: &str, payload: Value) -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id": "fixture", "command": command, "payload": payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn keymap_conflict_fixtures_cross_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/binding-conflicts-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = call_fixture_command( + "keymap.analyzeConflicts", + serde_json::json!({"platform": case["platform"], "layers": case["layers"]}), + ); + assert_eq!(result["ok"], true, "{case}"); + assert_eq!(result["data"], case["expected"], "{}", case["name"]); + } + let oversized: Vec = (0..20_001) + .map(|index| serde_json::json!({"command": format!("c{index}"), "key": "ctrl+a"})) + .collect(); + let rejected = call_fixture_command( + "keymap.analyzeConflicts", + serde_json::json!({"platform": "windows", "layers": [{"entries": oversized}]}), + ); + assert_eq!(rejected["ok"], false); +} + +#[test] +fn terminal_link_fixtures_cross_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/terminal/link-target-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = call_fixture_command("terminal.parseLinkTarget", case["request"].clone()); + assert_eq!(result["ok"], true, "{case}"); + assert_eq!( + result["data"]["targets"], case["expected"], + "{}", + case["name"] + ); + } +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5976b4192b..cbfff37c86 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -130,6 +130,44 @@ and native compilation to 16 outstanding requests. Cache limits, teardown and stale-store guards belong to the window adapter. macOS has no free-form `when` parser and is unchanged. See `shared/fixtures/keymap/context-v1.json`. +## Shortcut binding conflicts + +`keymap.analyzeConflicts` accepts `{platform: "macos" | "windows", layers: [{entries: [{command, key, when?, enabled?}], disabledCommands?: string[]}]}` +with layers ordered from the lowest priority (defaults) to the highest (user +overrides) and returns `{conflicts: [{key, when, commands}], invalid: [{command, key, code}]}`. +Within a layer, a command's entries replace every lower layer's entries for that +command; `disabledCommands` unbind a command from lower layers first. An entry with +`enabled: false` is in force but unbound. Conflicts are computed over the final +effective bindings only: two different commands with the same canonical key sequence +and whitespace-normalised `when` clause. Binding text is case-insensitive, modifier order +and aliases do not matter (`cmd`/`command`/`mod` follow `platform`: Command on macOS, Control on +Windows; `meta`/`super`/`win` are always Meta; `option`/`opt` are Alt; `return` is `enter`; `esc` is +`escape`), chords are whitespace-separated steps, a trailing `+` is the plus key, and `doubleshift` +is the double-tap Shift gesture. Parse failures are listed in `invalid` with `empty`, `empty_key`, +`multiple_keys`, `too_long` or `too_many_steps` and never take part in conflicts; more than +20000 entries is `invalid_request`. Hosts reject saving a binding when a conflict contains the edited +command. Native key-event conversion and the synchronous matching path stay in the host. +See `shared/fixtures/keymap/binding-conflicts-v1.json`. + +The double-Shift gesture state machine is intentionally not a Core command: it must +run synchronously on every native key event. Both hosts keep a thin implementation and +are tested step by step against `shared/fixtures/keymap/modifier-gesture-v1.json`. + +## Terminal link targets + +`terminal.parseLinkTarget` accepts `{texts: string[], baseDirectory?, homeDirectory?, providerSchemes?: string[]}` +and returns `{targets: (Target | null)[]}` with one entry per text. A target is +`{kind: "file", path, line, column, explicit}` or `{kind: "external", url}`. Core splits a trailing +`:line[:column]` (positive integers), decodes `file://` URIs (percent escapes, `localhost`, Windows drive +and UNC forms), expands `~` from `homeDirectory`, and resolves relative paths against `baseDirectory` +lexically (`.` and `..`), keeping Windows drive/UNC separators. `providerSchemes` name host file +providers (for example `remote`, `wsl`): such URIs are returned unchanged, and under a provider +`baseDirectory` relative and rooted POSIX paths stay inside that provider. Other `scheme://` texts and +`mailto:` are `external`; a bare `name:12` is a file, not a scheme. `explicit` is true when the text +itself says it is a path (separator, `~`, `file://`, provider URI or line suffix). Core never inspects +file extensions or touches the file system: the host checks existence, owns hit regions, and opens the +target. At most 256 texts of 4096 bytes. See `shared/fixtures/terminal/link-target-v1.json`. + The Rust core is the shared application runtime for macOS SwiftUI and Windows React/Tauri. macOS calls the stable C ABI while the Tauri host links the Rust crate directly. The C ABI remains: diff --git a/shared/fixtures/keymap/binding-conflicts-v1.json b/shared/fixtures/keymap/binding-conflicts-v1.json new file mode 100644 index 0000000000..94c8cb0683 --- /dev/null +++ b/shared/fixtures/keymap/binding-conflicts-v1.json @@ -0,0 +1,164 @@ +{ + "version": 1, + "cases": [ + { + "name": "modifier-order-and-aliases-are-the-same-binding", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "ctrl+shift+p" }, + { "command": "b", "key": "Shift+CTRL+P" }, + { "command": "c", "key": "cmd+shift+p" }, + { "command": "d", "key": "ctrl+alt+p" } + ] } + ], + "expected": { + "conflicts": [{ "key": "ctrl+shift+p", "when": null, "commands": ["a", "b", "c"] }], + "invalid": [] + } + }, + { + "name": "cmd-is-meta-on-macos-and-ctrl-on-windows", + "platform": "macos", + "layers": [ + { "entries": [ + { "command": "a", "key": "cmd+s" }, + { "command": "b", "key": "ctrl+s" }, + { "command": "c", "key": "meta+s" } + ] } + ], + "expected": { + "conflicts": [{ "key": "meta+s", "when": null, "commands": ["a", "c"] }], + "invalid": [] + } + }, + { + "name": "user-override-replaces-default-so-default-no-longer-conflicts", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "ctrl+k" }, + { "command": "b", "key": "ctrl+j" } + ] }, + { "entries": [{ "command": "b", "key": "ctrl+m" }] } + ], + "expected": { "conflicts": [], "invalid": [] } + }, + { + "name": "user-binding-collides-with-default-and-preset-bindings", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "default.only", "key": "ctrl+d" }, + { "command": "preset.replaced", "key": "ctrl+r" }, + { "command": "preset.disabled", "key": "ctrl+x" } + ] }, + { "entries": [{ "command": "preset.replaced", "key": "ctrl+e" }], "disabledCommands": ["preset.disabled"] }, + { "entries": [ + { "command": "user.one", "key": "ctrl+e" }, + { "command": "user.two", "key": "ctrl+x" }, + { "command": "user.three", "key": "ctrl+d" } + ] } + ], + "expected": { + "conflicts": [ + { "key": "ctrl+d", "when": null, "commands": ["default.only", "user.three"] }, + { "key": "ctrl+e", "when": null, "commands": ["preset.replaced", "user.one"] } + ], + "invalid": [] + } + }, + { + "name": "different-when-clauses-do-not-conflict-but-whitespace-does-not-matter", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "ctrl+t", "when": "editorFocus" }, + { "command": "b", "key": "ctrl+t", "when": "terminalFocus" }, + { "command": "c", "key": "ctrl+t", "when": "editorFocus " }, + { "command": "d", "key": "ctrl+t" }, + { "command": "e", "key": "ctrl+t", "when": " " } + ] } + ], + "expected": { + "conflicts": [ + { "key": "ctrl+t", "when": null, "commands": ["d", "e"] }, + { "key": "ctrl+t", "when": "editorFocus", "commands": ["a", "c"] } + ], + "invalid": [] + } + }, + { + "name": "disabled-entries-and-same-command-repeats-never-conflict", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "ctrl+g" }, + { "command": "a", "key": "ctrl+g" }, + { "command": "b", "key": "ctrl+g", "enabled": false } + ] } + ], + "expected": { "conflicts": [], "invalid": [] } + }, + { + "name": "disabled-user-entry-unbinds-the-default", + "platform": "windows", + "layers": [ + { "entries": [{ "command": "a", "key": "ctrl+g" }, { "command": "b", "key": "ctrl+h" }] }, + { "entries": [{ "command": "a", "key": "ctrl+h", "enabled": false }] } + ], + "expected": { "conflicts": [], "invalid": [] } + }, + { + "name": "chords-compare-every-step", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "ctrl+k ctrl+t" }, + { "command": "b", "key": "Ctrl+K Ctrl+T" }, + { "command": "c", "key": "ctrl+k ctrl+y" } + ] } + ], + "expected": { + "conflicts": [{ "key": "ctrl+k ctrl+t", "when": null, "commands": ["a", "b"] }], + "invalid": [] + } + }, + { + "name": "double-shift-gesture-conflicts-with-itself-only", + "platform": "macos", + "layers": [ + { "entries": [ + { "command": "search", "key": "doubleshift" }, + { "command": "other", "key": "doubleshift" }, + { "command": "plain", "key": "shift+a" } + ] } + ], + "expected": { + "conflicts": [{ "key": "doubleshift", "when": null, "commands": ["other", "search"] }], + "invalid": [] + } + }, + { + "name": "invalid-bindings-are-reported-and-skipped", + "platform": "windows", + "layers": [ + { "entries": [ + { "command": "a", "key": "" }, + { "command": "b", "key": "ctrl+" }, + { "command": "c", "key": "ctrl+a+b" }, + { "command": "d", "key": "ctrl++" }, + { "command": "e", "key": "ctrl++" } + ] } + ], + "expected": { + "conflicts": [{ "key": "ctrl++", "when": null, "commands": ["d", "e"] }], + "invalid": [ + { "command": "a", "key": "", "code": "empty" }, + { "command": "b", "key": "ctrl+", "code": "empty_key" }, + { "command": "c", "key": "ctrl+a+b", "code": "multiple_keys" } + ] + } + } + ] +} diff --git a/shared/fixtures/keymap/modifier-gesture-v1.json b/shared/fixtures/keymap/modifier-gesture-v1.json new file mode 100644 index 0000000000..d82bbda1a8 --- /dev/null +++ b/shared/fixtures/keymap/modifier-gesture-v1.json @@ -0,0 +1,612 @@ +{ + "version": 1, + "thresholdSeconds": 0.35, + "sequences": [ + { + "name": "two-standalone-taps-trigger", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": true + } + ] + }, + { + "name": "trigger-consumes-the-pending-tap", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": true + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.3, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.35, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.4, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.45, + "expected": true + } + ] + }, + { + "name": "typing-uppercase-between-press-and-release-is-not-a-tap", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "keyDown" + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "keyDown" + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "key-between-two-taps-cancels", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 2.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 2.05, + "expected": false + }, + { + "event": "keyDown" + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 2.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 2.25, + "expected": false + } + ] + }, + { + "name": "second-tap-after-threshold-starts-a-new-gesture", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.5, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.55, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.7, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.75, + "expected": true + } + ] + }, + { + "name": "interval-equal-to-threshold-does-not-trigger", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.35, + "expected": false + } + ] + }, + { + "name": "interval-just-under-threshold-triggers", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.349, + "expected": true + } + ] + }, + { + "name": "backwards-clock-never-triggers", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 5.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 5.0, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 4.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 4.1, + "expected": false + } + ] + }, + { + "name": "other-modifier-down-with-shift-is-not-standalone", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": true, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "other-modifier-added-while-shift-is-held-cancels", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": true, + "timestamp": 1.22, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "other-modifier-on-release-cancels-the-first-tap", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": true, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "other-modifier-change-without-shift-forgets-the-tap", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": true, + "timestamp": 1.1, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "held-shift-repeat-events-are-ignored", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.01, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.21, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": true + } + ] + }, + { + "name": "release-without-press-is-ignored", + "steps": [ + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.1, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.15, + "expected": false + } + ] + }, + { + "name": "reset-drops-pending-state", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "reset" + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.2, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.25, + "expected": false + } + ] + }, + { + "name": "reset-while-shift-is-down-forgets-the-press", + "steps": [ + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.0, + "expected": false + }, + { + "event": "reset" + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.05, + "expected": false + }, + { + "event": "flags", + "shift": true, + "otherModifiers": false, + "timestamp": 1.1, + "expected": false + }, + { + "event": "flags", + "shift": false, + "otherModifiers": false, + "timestamp": 1.15, + "expected": false + } + ] + } + ] +} diff --git a/shared/fixtures/terminal/link-target-v1.json b/shared/fixtures/terminal/link-target-v1.json new file mode 100644 index 0000000000..ccf9e992ad --- /dev/null +++ b/shared/fixtures/terminal/link-target-v1.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "cases": [ + { + "name": "relative-path-with-line-and-column", + "request": { "texts": ["Sources/App.swift:12:4", "src/lib.rs:7", "./a/../b.txt"], "baseDirectory": "/work/project" }, + "expected": [ + { "kind": "file", "path": "/work/project/Sources/App.swift", "line": 12, "column": 4, "explicit": true }, + { "kind": "file", "path": "/work/project/src/lib.rs", "line": 7, "column": null, "explicit": true }, + { "kind": "file", "path": "/work/project/b.txt", "line": null, "column": null, "explicit": true } + ] + }, + { + "name": "extension-is-never-inspected", + "request": { "texts": ["Makefile", "build/output", "LICENSE:3", "notes.zzz"], "baseDirectory": "/work/project" }, + "expected": [ + { "kind": "file", "path": "/work/project/Makefile", "line": null, "column": null, "explicit": false }, + { "kind": "file", "path": "/work/project/build/output", "line": null, "column": null, "explicit": true }, + { "kind": "file", "path": "/work/project/LICENSE", "line": 3, "column": null, "explicit": true }, + { "kind": "file", "path": "/work/project/notes.zzz", "line": null, "column": null, "explicit": false } + ] + }, + { + "name": "file-uri-is-decoded", + "request": { "texts": ["file:///Users/me/My%20Project/a%C3%A9.rs:9", "file://localhost/tmp/x.txt", "file:///C:/Users/me/a%20b.rs:3:1", "file:///tmp/%zz"], "baseDirectory": "/work" }, + "expected": [ + { "kind": "file", "path": "/Users/me/My Project/aé.rs", "line": 9, "column": null, "explicit": true }, + { "kind": "file", "path": "/tmp/x.txt", "line": null, "column": null, "explicit": true }, + { "kind": "file", "path": "C:\\Users\\me\\a b.rs", "line": 3, "column": 1, "explicit": true }, + null + ] + }, + { + "name": "web-urls-are-external-and-scheme-lookalikes-are-paths", + "request": { "texts": ["https://example.com/docs", "mailto:a@b.c", "App.swift:12"], "baseDirectory": "/work" }, + "expected": [ + { "kind": "external", "url": "https://example.com/docs" }, + { "kind": "external", "url": "mailto:a@b.c" }, + { "kind": "file", "path": "/work/App.swift", "line": 12, "column": null, "explicit": true } + ] + }, + { + "name": "tilde-needs-a-home-directory", + "request": { "texts": ["~/notes/todo.md:2", "~"], "baseDirectory": "/work", "homeDirectory": "/Users/me" }, + "expected": [ + { "kind": "file", "path": "/Users/me/notes/todo.md", "line": 2, "column": null, "explicit": true }, + { "kind": "file", "path": "/Users/me", "line": null, "column": null, "explicit": true } + ] + }, + { + "name": "tilde-without-home-and-relative-without-base-are-rejected", + "request": { "texts": ["~/a.txt", "src/a.txt", "/abs/a.txt:5"] }, + "expected": [ + null, + null, + { "kind": "file", "path": "/abs/a.txt", "line": 5, "column": null, "explicit": true } + ] + }, + { + "name": "windows-paths-keep-drive-and-separator-style", + "request": { "texts": ["src\\main.rs:10:2", "..\\other\\a.rs", "D:\\x\\y.txt:4", "\\\\srv\\share\\f.txt"], "baseDirectory": "C:\\work\\project" }, + "expected": [ + { "kind": "file", "path": "C:\\work\\project\\src\\main.rs", "line": 10, "column": 2, "explicit": true }, + { "kind": "file", "path": "C:\\work\\other\\a.rs", "line": null, "column": null, "explicit": true }, + { "kind": "file", "path": "D:\\x\\y.txt", "line": 4, "column": null, "explicit": true }, + { "kind": "file", "path": "\\\\srv\\share\\f.txt", "line": null, "column": null, "explicit": true } + ] + }, + { + "name": "provider-workspace-keeps-paths-inside-the-provider", + "request": { "texts": ["src/a.rs:3", "/etc/hosts", "wsl://Ubuntu/home/me/b.rs:1", "../c.rs"], "baseDirectory": "remote://conn1/home/me/app", "providerSchemes": ["remote", "wsl"] }, + "expected": [ + { "kind": "file", "path": "remote://conn1/home/me/app/src/a.rs", "line": 3, "column": null, "explicit": true }, + { "kind": "file", "path": "remote://conn1/etc/hosts", "line": null, "column": null, "explicit": true }, + { "kind": "file", "path": "wsl://Ubuntu/home/me/b.rs", "line": 1, "column": null, "explicit": true }, + { "kind": "file", "path": "remote://conn1/home/me/c.rs", "line": null, "column": null, "explicit": true } + ] + }, + { + "name": "empty-and-zero-location-texts", + "request": { "texts": ["", " ", "a.rs:0"], "baseDirectory": "/work" }, + "expected": [ + null, + null, + { "kind": "file", "path": "/work/a.rs:0", "line": null, "column": null, "explicit": false } + ] + } + ] +} diff --git a/shared/platform-feature-matrix/features/keymap-customization.json b/shared/platform-feature-matrix/features/keymap-customization.json index 4302645996..0612cd43cb 100644 --- a/shared/platform-feature-matrix/features/keymap-customization.json +++ b/shared/platform-feature-matrix/features/keymap-customization.json @@ -8,7 +8,11 @@ "macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift", "macos/Sources/Lithe/Models/Keymap", "macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift", - "macos/Tests/LitheTests/SearchEverywhereQueryModeTests.swift" + "macos/Tests/LitheTests/SearchEverywhereQueryModeTests.swift", + "macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift", + "macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift", + "shared/fixtures/keymap/binding-conflicts-v1.json", + "shared/fixtures/keymap/modifier-gesture-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -21,12 +25,19 @@ "windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx", "rust/lithe-core/src/keymap/mod.rs", "shared/fixtures/keymap/context-v1.json", - "windows/tauri/src/features/keymaps/utils/context.native.integration.test.ts" + "windows/tauri/src/features/keymaps/utils/context.native.integration.test.ts", + "rust/lithe-core/src/keymap/bindings.rs", + "shared/fixtures/keymap/binding-conflicts-v1.json", + "windows/tauri/src/features/keymaps/utils/binding-conflicts.ts", + "windows/tauri/src/features/keymaps/utils/save-keybinding.ts", + "windows/tauri/src/features/keymaps/utils/binding-conflicts.native.integration.test.ts", + "shared/fixtures/keymap/modifier-gesture-v1.json", + "windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Settings", "verification": "macOS 使用默认 Command-Shift-A 从编辑器、侧栏及第二个项目窗口打开 Find Action,确认输入聚焦、/ 命令过滤、Return 仅执行一次、Escape 关闭且重新打开无旧查询;弹窗已打开时再次调用应切换到 / 并聚焦。改绑、禁用、恢复及录制时确认菜单与分发使用有效绑定;无工作区或搜索模块不可用时应禁用,激活失败应关闭。IDEA Classic 和 Eclipse 的 Find Action 暂无默认绑定,普通 Search Everywhere 保留双击 Shift 和 Shift-Command-O。macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。", - "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;WS-09 / BUG-2 的 when 解析、校验和布尔结果由 Core 生成,导入/保存先校验,按键时只查预编译结果。验证导入 @、单个 &/|/=、未闭合引号/括号、缺少操作数和超预算条件时拒绝且保留原配置;检查坏历史条件不阻塞后续按键,并检查有效条件随焦点切换。原生 ABI 与 DOM 测试不替代真实 Windows WebView2、文件对话框及整机快捷键验收。" + "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;WS-09 / BUG-2 的 when 解析、校验和布尔结果由 Core 生成,导入/保存先校验,按键时只查预编译结果。验证导入 @、单个 &/|/=、未闭合引号/括号、缺少操作数和超预算条件时拒绝且保留原配置;检查坏历史条件不阻塞后续按键,并检查有效条件随焦点切换。原生 ABI 与 DOM 测试不替代真实 Windows WebView2、文件对话框及整机快捷键验收。 WS-08:快捷键冲突由 Core 的 keymap.analyzeConflicts 按最终生效绑定(默认、预设、用户逐层覆盖)判定,修饰键顺序、大小写与别名不同的等价绑定视为同一个;两端保存冲突绑定都会被拒绝,Windows 导入同样先整体校验。验证:Windows 把一个命令改绑成另一个命令的默认键(含不同修饰键顺序)应被拒绝并提示冲突命令,行内冲突提示覆盖默认和预设绑定;导入含冲突的文件时不改预设也不写入。WS-17:双击 Shift 手势状态机是有意保留的双端实现(macOS DoubleShiftGestureRecognizer,Windows double-shift-recognizer.ts):按键路径必须同步且不能每个原生按键事件走一次 Core IPC(Windows 的 Tauri invoke 是异步的),两端状态机由同一份 shared/fixtures/keymap/modifier-gesture-v1.json 逐步对照,改动任一端必须同时改 fixture 与两端测试。" } diff --git a/shared/platform-feature-matrix/features/terminal-interaction.json b/shared/platform-feature-matrix/features/terminal-interaction.json index 1b42d3d23c..845c958768 100644 --- a/shared/platform-feature-matrix/features/terminal-interaction.json +++ b/shared/platform-feature-matrix/features/terminal-interaction.json @@ -18,7 +18,10 @@ "macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift", "macos/Tests/LitheTests/GitLogReferenceResizeTests.swift", "macos/Tests/LitheTests/SplitHandleHitTestTests.swift", - "macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift" + "macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift", + "macos/Sources/Lithe/Core/Rust/RustTerminalLinks.swift", + "shared/fixtures/terminal/link-target-v1.json", + "macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,11 +32,15 @@ "windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts", ".agents/notes/implemented/simplification/2026-10-08-windows-terminal-direct-web-links.md", "windows/tauri/src/features/terminal/utils/terminal-keyboard.ts", - "windows/tauri/src/features/terminal/utils/terminal-keyboard.test.ts" + "windows/tauri/src/features/terminal/utils/terminal-keyboard.test.ts", + "rust/lithe-core/src/terminal/link.rs", + "shared/fixtures/terminal/link-target-v1.json", + "windows/tauri/src/features/terminal/utils/terminal-file-links.ts", + "windows/tauri/src/features/terminal/utils/terminal-file-links.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,除用户配置为切换终端的快捷键外,纯 Control 组合键(Ctrl-D、Ctrl-R、Ctrl-W)交回终端,不被其他应用命令截获;切换终端的绑定可从终端内关闭面板,其他修饰键组合仍走应用键位。TerminalShortcutInputPolicyTests 覆盖真实 AppKit 监听器及焦点策略;隔离的 macOS Release 包验证导入的 Control 加反引号可在终端内关闭,Control-D 仍可结束测试 shell,流程前后包内文件哈希一致。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。" + "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,除用户配置为切换终端的快捷键外,纯 Control 组合键(Ctrl-D、Ctrl-R、Ctrl-W)交回终端,不被其他应用命令截获;切换终端的绑定可从终端内关闭面板,其他修饰键组合仍走应用键位。TerminalShortcutInputPolicyTests 覆盖真实 AppKit 监听器及焦点策略;隔离的 macOS Release 包验证导入的 Control 加反引号可在终端内关闭,Control-D 仍可结束测试 shell,流程前后包内文件哈希一致。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。 WS-16:终端文件链接的 path:line:column、file URI 解码、~ 展开和相对路径映射由 Core terminal.parseLinkTarget 统一解析,两端只负责命中区域、文件是否存在和打开动作。Windows 不再按扩展名白名单筛选,而是对含路径分隔符、~、file:// 或 :行号 的候选逐个检查文件是否存在,所以无扩展名文件(如 build/output)也能点开,不存在的路径不再被画成链接;远程/WSL 路径没有单文件 stat 接口,改为列出父目录(与文件树同一个 ssh_read_directory / wsl_read_directory 适配器)判断文件是否存在,所以不带行号的 src/foo.ts 仍可点开,本地、WSL、远程三种根行为一致,没有扩展名启发式例外。同一行同一工作目录的结果缓存 2 秒(最多 128 条,进行中的请求共享),避免悬停时反复检查。macOS 带 :行号 的相对文件名(如 App.swift:12)不再被误当成 URL scheme。真实终端点击验收 pending。" } diff --git a/windows/tauri/src/features/keymaps/components/keybinding-row.tsx b/windows/tauri/src/features/keymaps/components/keybinding-row.tsx index 99a970c2f5..9fe344ebfb 100644 --- a/windows/tauri/src/features/keymaps/components/keybinding-row.tsx +++ b/windows/tauri/src/features/keymaps/components/keybinding-row.tsx @@ -12,9 +12,12 @@ import { useKeybindingConflicts } from "../hooks/use-keybinding-conflicts"; import { useKeymapStore } from "../stores/keymaps.store"; import type { Command, Keybinding } from "../types/keymaps.types"; import { localizeKeymapCommand } from "../utils/command-localization"; +import { keymapRegistry } from "../utils/registry"; import { KeybindingInput } from "./keybinding-input"; import { useToast } from "@/features/layout/contexts/toast-context"; import { KeymapContextError } from "../utils/context"; +import { saveUserKeybinding } from "../utils/save-keybinding"; +import { KeybindingConflictError } from "../utils/binding-conflicts"; export const keybindingTableMinWidth = cva("min-w-175"); @@ -27,33 +30,38 @@ export function KeybindingRow({ command, keybinding }: KeybindingRowProps) { const { t } = useTranslation(); const { showToast } = useToast(); const [isEditing, setIsEditing] = useState(false); - const { addKeybinding, updateKeybinding, removeKeybinding } = useKeymapStore.use.actions(); - const { hasConflict, conflictingCommands } = useKeybindingConflicts( - keybinding?.key || "", - command.id, - keybinding?.when, - ); + const { removeKeybinding } = useKeymapStore.use.actions(); + const { hasConflict, conflictingCommands } = useKeybindingConflicts(command.id); const handleSave = async (newKey: string) => { try { - if (keybinding?.source === "user") { - await updateKeybinding(command.id, { key: newKey }); - } else { - await addKeybinding({ - key: newKey, - command: command.id, - source: "user", - enabled: true, - when: keybinding?.when, - }); - } + await saveUserKeybinding( + keybinding?.source === "user" + ? { ...keybinding, key: newKey } + : { + key: newKey, + command: command.id, + source: "user", + enabled: true, + when: keybinding?.when, + }, + ); setIsEditing(false); } catch (error) { showToast({ message: - error instanceof KeymapContextError - ? t("keybindings.invalidWhen") - : t("keybindings.saveFailed", { error: String(error) }), + error instanceof KeybindingConflictError + ? t("keybindings.conflictsWith", { + commands: error.commands + .map((id) => { + const other = keymapRegistry.getCommand(id); + return other ? localizeKeymapCommand(other, t).title : id; + }) + .join(", "), + }) + : error instanceof KeymapContextError + ? t("keybindings.invalidWhen") + : t("keybindings.saveFailed", { error: String(error) }), type: "error", }); } diff --git a/windows/tauri/src/features/keymaps/hooks/use-keybinding-conflicts.ts b/windows/tauri/src/features/keymaps/hooks/use-keybinding-conflicts.ts index 5c369e1d65..93141bac56 100644 --- a/windows/tauri/src/features/keymaps/hooks/use-keybinding-conflicts.ts +++ b/windows/tauri/src/features/keymaps/hooks/use-keybinding-conflicts.ts @@ -1,6 +1,8 @@ -import { useMemo } from "react"; +import { useEffect, useState } from "react"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useKeymapStore } from "../stores/keymaps.store"; import type { Command } from "../types/keymaps.types"; +import { analyzeKeybindings, conflictingCommandIds } from "../utils/binding-conflicts"; import { keymapRegistry } from "../utils/registry"; interface ConflictInfo { @@ -8,43 +10,39 @@ interface ConflictInfo { conflictingCommands: Command[]; } -export function useKeybindingConflicts( - keybinding: string, - currentCommandId: string, - whenClause?: string, -): ConflictInfo { - const keybindings = useKeymapStore.use.keybindings(); - - const conflictInfo = useMemo(() => { - if (!keybinding) { - return { hasConflict: false, conflictingCommands: [] }; - } - - const conflicting = keybindings.filter((kb) => { - if (kb.command === currentCommandId) return false; - if (kb.key !== keybinding) return false; - if (!kb.enabled) return false; - - if (whenClause === kb.when) { - return true; - } - - if (!whenClause && !kb.when) { - return true; - } - - return false; - }); - - const conflictingCommands = conflicting - .map((kb) => keymapRegistry.getCommand(kb.command)) - .filter((cmd): cmd is Command => cmd !== undefined); - - return { - hasConflict: conflictingCommands.length > 0, - conflictingCommands, +const NO_CONFLICT: ConflictInfo = { hasConflict: false, conflictingCommands: [] }; + +/** Conflicts of one command among the final effective bindings, as computed by Core. */ +export function useKeybindingConflicts(currentCommandId: string): ConflictInfo { + const userKeybindings = useKeymapStore.use.keybindings(); + const preset = useSettingsStore((state) => state.settings.keybindingPreset); + const [info, setInfo] = useState(NO_CONFLICT); + + useEffect(() => { + let cancelled = false; + void analyzeKeybindings({ + preset, + registryKeybindings: keymapRegistry.getAllKeybindings(), + userKeybindings, + }) + .then((analysis) => { + if (cancelled) return; + const conflictingCommands = conflictingCommandIds(analysis, currentCommandId) + .map((id) => keymapRegistry.getCommand(id)) + .filter((command): command is Command => command !== undefined); + setInfo( + conflictingCommands.length > 0 + ? { hasConflict: true, conflictingCommands } + : NO_CONFLICT, + ); + }) + .catch(() => { + if (!cancelled) setInfo(NO_CONFLICT); + }); + return () => { + cancelled = true; }; - }, [keybinding, keybindings, currentCommandId, whenClause]); + }, [currentCommandId, preset, userKeybindings]); - return conflictInfo; + return info; } diff --git a/windows/tauri/src/features/keymaps/utils/binding-conflicts.native.integration.test.ts b/windows/tauri/src/features/keymaps/utils/binding-conflicts.native.integration.test.ts new file mode 100644 index 0000000000..c946e956bc --- /dev/null +++ b/windows/tauri/src/features/keymaps/utils/binding-conflicts.native.integration.test.ts @@ -0,0 +1,72 @@ +import { afterAll, expect, spyOn, test } from "bun:test"; +import { CString, dlopen, FFIType } from "bun:ffi"; +import * as native from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/keymap/binding-conflicts-v1.json"; +import { + assertNoKeybindingConflicts, + KeybindingConflictError, +} from "./binding-conflicts"; +import { keymapRegistry } from "./registry"; + +const libraryPath = process.env.LITHE_CORE_TEST_LIBRARY; +const library = libraryPath + ? dlopen(libraryPath, { + lithe_core_execute_json: { args: [FFIType.ptr], returns: FFIType.ptr }, + lithe_core_free_string: { args: [FFIType.ptr], returns: FFIType.void }, + }) + : null; +afterAll(() => library?.close()); + +function callCore(command: string, payload: unknown) { + const input = Buffer.from(JSON.stringify({ id: "conflicts", command, payload }) + "\0"); + const pointer = library!.symbols.lithe_core_execute_json(input); + if (!pointer) throw new Error("Core returned null"); + try { + const response = JSON.parse(new CString(pointer).toString()); + if (!response.ok) throw new Error(response.error.message); + return response.data; + } finally { + library!.symbols.lithe_core_free_string(pointer); + } +} + +for (const item of fixture.cases.filter((candidate) => candidate.platform === "windows")) { + test.skipIf(!library)(`native Core keymap conflicts: ${item.name}`, () => { + expect(callCore("keymap.analyzeConflicts", { platform: item.platform, layers: item.layers })).toEqual( + item.expected, + ); + }); +} + +test.skipIf(!library)("a save that collides with a default is rejected before it is stored", async () => { + const invoke = spyOn(native, "invoke").mockImplementation((async (command: string, payload: unknown) => + callCore(command, payload)) as typeof native.invoke); + const registry = spyOn(keymapRegistry, "getAllKeybindings").mockReturnValue([ + { key: "ctrl+d", command: "default.only", source: "default" }, + ]); + try { + await expect( + assertNoKeybindingConflicts( + { + preset: "none", + registryKeybindings: keymapRegistry.getAllKeybindings(), + userKeybindings: [{ key: "Ctrl+D", command: "user.new", source: "user", enabled: true }], + }, + ["user.new"], + ), + ).rejects.toEqual(expect.any(KeybindingConflictError)); + await expect( + assertNoKeybindingConflicts( + { + preset: "none", + registryKeybindings: keymapRegistry.getAllKeybindings(), + userKeybindings: [{ key: "ctrl+e", command: "user.new", source: "user", enabled: true }], + }, + ["user.new"], + ), + ).resolves.toBeUndefined(); + } finally { + registry.mockRestore(); + invoke.mockRestore(); + } +}); diff --git a/windows/tauri/src/features/keymaps/utils/binding-conflicts.ts b/windows/tauri/src/features/keymaps/utils/binding-conflicts.ts new file mode 100644 index 0000000000..6e3a41c83e --- /dev/null +++ b/windows/tauri/src/features/keymaps/utils/binding-conflicts.ts @@ -0,0 +1,70 @@ +/** + * Asks Core which shortcuts collide once defaults, the preset and user overrides are layered. + * The grammar of binding strings and the layering rules live in Core; this file only + * projects the stores into Core's layer format. It never runs on the key-dispatch path. + */ +import { invoke } from "@/platform/tauri-core"; +import type { Settings } from "@/features/settings/types/settings.types"; +import { getKeybindingPresetDefinition } from "../defaults/keybinding-presets"; +import type { Keybinding } from "../types/keymaps.types"; + +export interface BindingConflict { + key: string; + when: string | null; + commands: string[]; +} + +interface ConflictAnalysis { + conflicts: BindingConflict[]; + invalid: { command: string; key: string; code: string }[]; +} + +export interface KeybindingLayersInput { + preset: Settings["keybindingPreset"]; + registryKeybindings: Keybinding[]; + userKeybindings: Keybinding[]; +} + +/** Rejected save: the proposed binding is already in force for other commands. */ +export class KeybindingConflictError extends Error { + constructor(readonly commands: string[]) { + super("keybinding_conflict"); + } +} + +function toEntries(bindings: Keybinding[]) { + return bindings.map(({ command, key, when, enabled }) => ({ command, key, when, enabled })); +} + +export function analyzeKeybindings({ + preset, + registryKeybindings, + userKeybindings, +}: KeybindingLayersInput): Promise { + const { overrides, disabledCommands } = getKeybindingPresetDefinition(preset); + return invoke("keymap.analyzeConflicts", { + platform: "windows", + layers: [ + { entries: toEntries(registryKeybindings) }, + { entries: toEntries(overrides), disabledCommands }, + { entries: toEntries(userKeybindings) }, + ], + }); +} + +/** Command IDs that share an effective key and `when` clause with `commandId`. */ +export function conflictingCommandIds(analysis: ConflictAnalysis, commandId: string): string[] { + return analysis.conflicts + .filter((conflict) => conflict.commands.includes(commandId)) + .flatMap((conflict) => conflict.commands.filter((command) => command !== commandId)); +} + +/** Throws before anything is stored if the proposed layers make any of `commandIds` collide. */ +export async function assertNoKeybindingConflicts( + input: KeybindingLayersInput, + commandIds: string[], +): Promise { + const analysis = await analyzeKeybindings(input); + const commands = new Set(commandIds.flatMap((id) => conflictingCommandIds(analysis, id))); + if (commands.size > 0) throw new KeybindingConflictError([...commands]); +} diff --git a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts index 356a069ce4..4136fd4545 100644 --- a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts +++ b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts @@ -1,40 +1,28 @@ import { describe, expect, test } from "bun:test"; -import { DoubleShiftGestureRecognizer } from "./double-shift-recognizer"; +import fixture from "../../../../../../shared/fixtures/keymap/modifier-gesture-v1.json"; +import { DoubleShiftGestureRecognizer, DOUBLE_SHIFT_THRESHOLD_SECONDS } from "./double-shift-recognizer"; -describe("double Shift search everywhere", () => { - test("requires two standalone taps within the threshold", () => { - const recognizer = new DoubleShiftGestureRecognizer(0.35); - - expect(recognizer.handleFlagsChanged(true, false, 1.0)).toBe(false); - expect(recognizer.handleFlagsChanged(false, false, 1.05)).toBe(false); - expect(recognizer.handleFlagsChanged(true, false, 1.2)).toBe(false); - expect(recognizer.handleFlagsChanged(false, false, 1.25)).toBe(true); - }); - - test("rejects Shift used for uppercase typing and keys between taps", () => { - const recognizer = new DoubleShiftGestureRecognizer(0.35); - - recognizer.handleFlagsChanged(true, false, 1.0); - recognizer.handleKeyDown(); - expect(recognizer.handleFlagsChanged(false, false, 1.05)).toBe(false); - - recognizer.handleFlagsChanged(true, false, 1.2); - recognizer.handleKeyDown(); - expect(recognizer.handleFlagsChanged(false, false, 1.25)).toBe(false); - - recognizer.handleFlagsChanged(true, false, 2.0); - recognizer.handleFlagsChanged(false, false, 2.05); - recognizer.handleKeyDown(); - recognizer.handleFlagsChanged(true, false, 2.2); - expect(recognizer.handleFlagsChanged(false, false, 2.25)).toBe(false); +// The same fixture drives the Swift recognizer, so the two platform state machines cannot drift. +describe("double Shift gesture (shared fixture)", () => { + test("threshold matches the fixture", () => { + expect(DOUBLE_SHIFT_THRESHOLD_SECONDS).toBe(fixture.thresholdSeconds); }); - test("ignores a second tap after the threshold", () => { - const recognizer = new DoubleShiftGestureRecognizer(0.35); - - recognizer.handleFlagsChanged(true, false, 1.0); - recognizer.handleFlagsChanged(false, false, 1.05); - recognizer.handleFlagsChanged(true, false, 1.5); - expect(recognizer.handleFlagsChanged(false, false, 1.55)).toBe(false); - }); + for (const sequence of fixture.sequences) { + test(sequence.name, () => { + const recognizer = new DoubleShiftGestureRecognizer(fixture.thresholdSeconds); + for (const [index, step] of sequence.steps.entries()) { + if (step.event === "keyDown") recognizer.handleKeyDown(); + else if (step.event === "reset") recognizer.reset(); + else { + const triggered = recognizer.handleFlagsChanged( + step.shift!, + step.otherModifiers!, + step.timestamp!, + ); + expect({ step: index, triggered }).toEqual({ step: index, triggered: step.expected! }); + } + } + }); + } }); diff --git a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts index 7148ad9836..8c0c64c637 100644 --- a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts +++ b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts @@ -1,3 +1,9 @@ +/** + * Double-Shift gesture state machine. It runs synchronously on every key event, so it + * deliberately stays out of Core (no per-key IPC). macOS keeps the same machine in + * MacShortcutDetector.swift; both are tested against + * shared/fixtures/keymap/modifier-gesture-v1.json and must change together. + */ export const DOUBLE_SHIFT_THRESHOLD_SECONDS = 0.35; export class DoubleShiftGestureRecognizer { diff --git a/windows/tauri/src/features/keymaps/utils/save-keybinding.ts b/windows/tauri/src/features/keymaps/utils/save-keybinding.ts new file mode 100644 index 0000000000..b6afa5f5ec --- /dev/null +++ b/windows/tauri/src/features/keymaps/utils/save-keybinding.ts @@ -0,0 +1,42 @@ +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import type { KeybindingPreset } from "../defaults/keybinding-presets"; +import { useKeymapStore } from "../stores/keymaps.store"; +import type { Keybinding } from "../types/keymaps.types"; +import { assertNoKeybindingConflicts } from "./binding-conflicts"; +import { normalizeUserKeybinding } from "./keybinding-import-export"; +import { keymapRegistry } from "./registry"; + +/** + * Throws a `KeybindingConflictError` unless Core confirms none of `bindings` collides with + * another command's effective binding (defaults, preset or user). Nothing is stored here; + * `preset` is the preset that will be active once the bindings are applied. + */ +export async function assertUserKeybindingsAllowed( + bindings: Keybinding[], + options: { preset?: KeybindingPreset } = {}, +): Promise { + const normalized = bindings + .map((binding) => normalizeUserKeybinding(binding)) + .filter((binding): binding is Keybinding => binding !== null); + const commandIds = new Set(normalized.map((binding) => binding.command)); + await assertNoKeybindingConflicts( + { + preset: options.preset ?? useSettingsStore.getState().settings.keybindingPreset, + registryKeybindings: keymapRegistry.getAllKeybindings(), + userKeybindings: [ + ...useKeymapStore + .getState() + .keybindings.filter((binding) => !commandIds.has(binding.command)), + ...normalized, + ], + }, + [...commandIds], + ); + return normalized; +} + +/** Stores one edited binding only if it does not collide with an effective binding. */ +export async function saveUserKeybinding(binding: Keybinding): Promise { + const [allowed] = await assertUserKeybindingsAllowed([binding]); + if (allowed) await useKeymapStore.getState().actions.addKeybinding(allowed); +} diff --git a/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx b/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx index e1433a97ac..713e65da70 100644 --- a/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx +++ b/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx @@ -6,6 +6,7 @@ import { installHappyDom } from "@/test-utils/happy-dom"; import * as animation from "motion/react"; import * as nativeDialog from "@tauri-apps/plugin-dialog"; import * as nativeFs from "@tauri-apps/plugin-fs"; +import { installKeymapContextPlans } from "@/test-utils/keymap-context-plans"; import * as toast from "@/features/layout/contexts/toast-context"; import { useKeymapStore } from "@/features/keymaps/stores/keymaps.store"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; @@ -56,6 +57,14 @@ test("keyboard settings route supports searching, recording and resetting a comm dismissToast: () => {}, hasToast: () => false, }); + // Core's answer for this scenario (no other command owns alt+ctrl+b) is "no conflicts"; + // binding-conflicts.native.integration.test.ts proves Core's side against the shared fixture. + const analyses: { platform: string; layers: { entries: { command: string; key: string }[] }[] }[] = []; + const contextPlans = installKeymapContextPlans((command, payload) => { + if (command !== "keymap.analyzeConflicts") return undefined; + analyses.push(payload as (typeof analyses)[number]); + return { conflicts: [], invalid: [] }; + }); let importInput: HTMLInputElement | undefined; const chooseFile = spyOn(HTMLInputElement.prototype, "click").mockImplementation( function (this: HTMLInputElement) { @@ -156,6 +165,14 @@ test("keyboard settings route supports searching, recording and resetting a comm }, ]); expect(useKeymapStore.getState().recordingCommandId).toBeNull(); + // The save asked Core about the final layered bindings: registry, preset, then user. + const saveAnalysis = analyses.find((analysis) => + analysis.layers[2]?.entries.some((entry) => entry.key === "alt+ctrl+b"), + )!; + expect(saveAnalysis.platform).toBe("windows"); + expect(saveAnalysis.layers[0]!.entries).toEqual([ + expect.objectContaining({ command: "editor.goToImplementation", key: "ctrl+f12" }), + ]); await act(async () => button("Export").click()); expect(writeFile).toHaveBeenCalledTimes(1); const [path, exported] = writeFile.mock.calls[0]!; @@ -182,6 +199,7 @@ test("keyboard settings route supports searching, recording and resetting a comm await act(async () => root?.unmount()); } finally { container.remove(); + contextPlans.mockRestore(); chooseFile.mockRestore(); notifications.mockRestore(); writeFile.mockRestore(); diff --git a/windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx b/windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx index 2126c92b23..44c4338f63 100644 --- a/windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx +++ b/windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx @@ -26,7 +26,9 @@ import { useKeymapStore } from "@/features/keymaps/stores/keymaps.store"; import type { Keybinding } from "@/features/keymaps/types/keymaps.types"; import { getEffectiveKeybindingForCommand } from "@/features/keymaps/utils/effective-keymaps"; import { localizeKeymapCommand } from "@/features/keymaps/utils/command-localization"; +import { KeybindingConflictError } from "@/features/keymaps/utils/binding-conflicts"; import { KeymapContextError } from "@/features/keymaps/utils/context"; +import { assertUserKeybindingsAllowed } from "@/features/keymaps/utils/save-keybinding"; import { createKeybindingsExportPayload, getExportableUserKeybindings, @@ -207,6 +209,10 @@ export const KeyboardSettings = () => { return; } + // Core checks the final layered bindings (imported preset included) before anything changes. + await assertUserKeybindingsAllowed(imported.keybindings, { + preset: imported.keybindingPreset, + }); if (imported.keybindingPreset) { await updateSetting("keybindingPreset", imported.keybindingPreset); } @@ -226,9 +232,18 @@ export const KeyboardSettings = () => { } catch (error) { showToast({ message: - error instanceof KeymapContextError - ? t("keybindings.invalidWhen") - : t("settings.keyboard.importFailed", { error: String(error) }), + error instanceof KeybindingConflictError + ? t("keybindings.conflictsWith", { + commands: error.commands + .map((id) => { + const other = keymapRegistry.getCommand(id); + return other ? localizeKeymapCommand(other, t).title : id; + }) + .join(", "), + }) + : error instanceof KeymapContextError + ? t("keybindings.invalidWhen") + : t("settings.keyboard.importFailed", { error: String(error) }), type: "error", }); } diff --git a/windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts b/windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts index 5066edb835..49899cf26c 100644 --- a/windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts +++ b/windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts @@ -8,7 +8,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm"; import { - parseTerminalFileLinks, + findTerminalFileLinks, type TerminalFileLink, } from "@/features/terminal/utils/terminal-file-links"; import { writeClipboardText } from "@/utils/clipboard"; @@ -97,27 +97,27 @@ export function registerFileLinksProvider( return; } - const links = parseTerminalFileLinks( - line.translateToString(true), - options.getWorkspaceRoot(), - ); - if (links.length === 0) { - callback(undefined); - return; - } - - callback( - links.map((link) => ({ - range: { - start: { x: link.startIndex + 1, y: bufferLineNumber }, - end: { x: link.endIndex, y: bufferLineNumber }, - }, - text: link.text, - activate: () => { - void options.openFile(link); - }, - })), - ); + void findTerminalFileLinks(line.translateToString(true), options.getWorkspaceRoot()) + .catch(() => []) + .then((links) => { + if (links.length === 0) { + callback(undefined); + return; + } + + callback( + links.map((link) => ({ + range: { + start: { x: link.startIndex + 1, y: bufferLineNumber }, + end: { x: link.endIndex, y: bufferLineNumber }, + }, + text: link.text, + activate: () => { + void options.openFile(link); + }, + })), + ); + }); }, }; diff --git a/windows/tauri/src/features/terminal/utils/terminal-file-links.test.ts b/windows/tauri/src/features/terminal/utils/terminal-file-links.test.ts new file mode 100644 index 0000000000..9533c96935 --- /dev/null +++ b/windows/tauri/src/features/terminal/utils/terminal-file-links.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/terminal/link-target-v1.json"; + +const existing = new Set(); +mock.module("@tauri-apps/plugin-fs", () => ({ + stat: async (path: string) => { + if (!existing.has(path)) throw new Error("missing"); + return { isFile: true }; + }, +})); +mock.module("@tauri-apps/api/path", () => ({ homeDir: async () => "/Users/me" })); + +const { findTerminalFileLinks, createTerminalFileLinkFinder, defaultTerminalLinkDeps } = + await import("./terminal-file-links"); + +type FixtureCase = (typeof fixture.cases)[number]; +const byName = (name: string) => fixture.cases.find((item) => item.name === name) as FixtureCase; + +let invoke: ReturnType; +beforeEach(() => { + existing.clear(); +}); +afterEach(() => invoke?.mockRestore()); + +/** Answers `terminal.parseLinkTarget` with the Core-verified fixture result for the same texts. */ +function answerFromFixture(item: FixtureCase) { + invoke = spyOn(native, "invoke").mockImplementation((async (command: string, payload: unknown) => { + expect(command).toBe("terminal.parseLinkTarget"); + expect((payload as { texts: string[] }).texts).toEqual(item.request.texts); + expect((payload as { baseDirectory?: string }).baseDirectory).toBe(item.request.baseDirectory); + return { targets: item.expected }; + }) as typeof native.invoke); +} + +describe("terminal file links", () => { + test("keeps hit regions on the trimmed token and opens only existing files", async () => { + answerFromFixture(byName("relative-path-with-line-and-column")); + existing.add("/work/project/Sources/App.swift"); + const line = "(Sources/App.swift:12:4), src/lib.rs:7 ./a/../b.txt."; + + const links = await findTerminalFileLinks(line, "/work/project"); + + expect(links).toEqual([ + { + text: "Sources/App.swift:12:4", + path: "/work/project/Sources/App.swift", + line: 12, + column: 4, + startIndex: 1, + endIndex: 23, + }, + ]); + }); + + test("does not depend on file extensions and ignores bare words", async () => { + answerFromFixture(byName("extension-is-never-inspected")); + for (const path of ["Makefile", "build/output", "LICENSE", "notes.zzz"]) { + existing.add(`/work/project/${path}`); + } + + const links = await findTerminalFileLinks( + "Makefile build/output LICENSE:3 notes.zzz", + "/work/project", + ); + + expect(links.map((link) => [link.text, link.path, link.line])).toEqual([ + ["build/output", "/work/project/build/output", undefined], + ["LICENSE:3", "/work/project/LICENSE", 3], + ]); + }); + + test("remote and WSL files are found by listing their parent, with or without a line", async () => { + const item = byName("provider-workspace-keeps-paths-inside-the-provider"); + const listed: string[] = []; + const find = createTerminalFileLinkFinder({ + ...defaultTerminalLinkDeps, + resolveTargets: async () => item.expected as never, + listDirectory: async (provider) => { + listed.push(provider); + return provider === "wsl://Ubuntu/home/me" + ? [{ name: "b.rs", is_dir: false }] + : [ + { name: "a.rs", is_dir: false }, + { name: "c.rs", is_dir: false }, + { name: "src", is_dir: true }, + ]; + }, + }); + + const links = await find("src/a.rs:3 /etc/hosts wsl://Ubuntu/home/me/b.rs:1 ../c.rs", "remote://conn1/home/me/app"); + + // /etc/hosts is absent from its listing, so it is not a link; ../c.rs needs no line. + expect(links.map((link) => link.path)).toEqual([ + "remote://conn1/home/me/app/src/a.rs", + "wsl://Ubuntu/home/me/b.rs", + "remote://conn1/home/me/c.rs", + ]); + expect(listed.sort()).toEqual([ + "remote://conn1/etc", + "remote://conn1/home/me", + "remote://conn1/home/me/app/src", + "wsl://Ubuntu/home/me", + ]); + }); + + test("repeated hovers share in-flight work and expire on a deterministic clock", async () => { + let clock = 0; + let resolves = 0; + let stats = 0; + const find = createTerminalFileLinkFinder({ + now: () => clock, + resolveTargets: async () => { + resolves++; + return [{ kind: "file", path: "/w/a/b.txt", line: null, column: null, explicit: true }]; + }, + statFile: async () => { + stats++; + return true; + }, + listDirectory: async () => [], + }); + + const [first, second] = await Promise.all([find("a/b.txt", "/w"), find("a/b.txt", "/w")]); + expect(first).toBe(second); + clock = 1999; + await find("a/b.txt", "/w"); + expect([resolves, stats]).toEqual([1, 1]); + // Same line under another working directory is a different key. + await find("a/b.txt", "/other"); + expect([resolves, stats]).toEqual([2, 2]); + clock = 2000; + await find("a/b.txt", "/w"); + expect([resolves, stats]).toEqual([3, 3]); + }); +}); diff --git a/windows/tauri/src/features/terminal/utils/terminal-file-links.ts b/windows/tauri/src/features/terminal/utils/terminal-file-links.ts index 53290f6ccf..9b3c6e97ee 100644 --- a/windows/tauri/src/features/terminal/utils/terminal-file-links.ts +++ b/windows/tauri/src/features/terminal/utils/terminal-file-links.ts @@ -1,6 +1,8 @@ +import { homeDir } from "@tauri-apps/api/path"; +import { stat } from "@tauri-apps/plugin-fs"; +import { invoke } from "@/platform/tauri-core"; import { parseRemotePath } from "@/features/remote/utils/remote-path"; -import { buildWslPath, parseWslPath } from "@/features/wsl/utils/wsl-path"; -import { joinPath } from "@/utils/path-helpers"; +import { parseWslPath } from "@/features/wsl/utils/wsl-path"; export interface TerminalFileLink { text: string; @@ -11,150 +13,187 @@ export interface TerminalFileLink { endIndex: number; } -const LEADING_TRIM_RE = /^[`"'(<[{]+/; -const TRAILING_TRIM_RE = /[`"')>\]},.;!:]+$/; -const LINE_COLUMN_RE = /^(.*?)(?::(\d+))(?::(\d+))?$/; -const SUPPORTED_URI_RE = /^(file|remote|wsl):\/\//i; -const UNSUPPORTED_URI_RE = /^[a-z][a-z0-9+.-]*:\/\//i; -const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; -const EXTENSIONLESS_FILENAMES = new Set([ - "AGENTS", - "Brewfile", - "Dockerfile", - "Gemfile", - "Justfile", - "LICENSE", - "Makefile", - "Procfile", - "Rakefile", - "README", -]); - -function trimToken(rawToken: string, tokenStart: number) { - let text = rawToken; - let startIndex = tokenStart; +type CoreLinkTarget = + | { kind: "file"; path: string; line: number | null; column: number | null; explicit: boolean } + | { kind: "external"; url: string }; - const leadingMatch = text.match(LEADING_TRIM_RE); - if (leadingMatch?.[0]) { - text = text.slice(leadingMatch[0].length); - startIndex += leadingMatch[0].length; - } - - text = text.replace(TRAILING_TRIM_RE, ""); - - return { - text, - startIndex, - endIndex: startIndex + text.length, - }; +interface Token { + text: string; + startIndex: number; + endIndex: number; } -function decodeFileUri(path: string) { - if (!path.toLowerCase().startsWith("file://")) return path; +/** Providers whose files cannot be stat'ed from the webview; they are checked by listing the parent. */ +const PROVIDER_SCHEMES = ["remote", "wsl"]; +const MAX_CANDIDATES_PER_LINE = 64; +const CACHE_LIMIT = 128; +const CACHE_TTL_MS = 2000; +const LEADING_TRIM_RE = /^[`"'(<[{]+/; +const TRAILING_TRIM_RE = /[`"')>\]},.;!:]+$/; - try { - const url = new URL(path); - if (url.protocol !== "file:") return path; - return decodeURIComponent(url.pathname.replace(/^\/([A-Za-z]:\/)/, "$1")); - } catch { - return path.slice("file://".length); - } +interface DirectoryEntry { + name: string; + is_dir: boolean; } -function extractLineColumn(text: string) { - const match = text.match(LINE_COLUMN_RE); - if (!match) return { path: text }; - - const [, path, lineText, columnText] = match; - if (!path || WINDOWS_ABSOLUTE_RE.test(text)) { - if (WINDOWS_ABSOLUTE_RE.test(text) && !lineText) return { path: text }; - } - - return { - path, - line: Number(lineText), - column: columnText ? Number(columnText) : undefined, - }; +export interface TerminalLinkDeps { + now: () => number; + statFile: (path: string) => Promise; + listDirectory: (provider: string) => Promise; + resolveTargets: ( + texts: string[], + baseDirectory: string | undefined, + ) => Promise<(CoreLinkTarget | null)[]>; } -function getBaseName(path: string) { - const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); - return normalized.split("/").filter(Boolean).pop() ?? ""; +let homeDirectory: Promise | undefined; +function getHomeDirectory(): Promise { + homeDirectory ??= homeDir().catch(() => undefined); + return homeDirectory; } -function hasPathShape(path: string) { - if (SUPPORTED_URI_RE.test(path)) return true; - if (path.startsWith("/") || WINDOWS_ABSOLUTE_RE.test(path)) return true; - if (path.startsWith("./") || path.startsWith("../")) return true; - return path.includes("/") || path.includes("\\"); +/** Hit region of a whitespace-delimited token without surrounding punctuation. */ +function trimToken(rawToken: string, tokenStart: number): Token { + let text = rawToken; + let startIndex = tokenStart; + const leading = text.match(LEADING_TRIM_RE)?.[0]; + if (leading) { + text = text.slice(leading.length); + startIndex += leading.length; + } + text = text.replace(TRAILING_TRIM_RE, ""); + return { text, startIndex, endIndex: startIndex + text.length }; } -function hasFileNameShape(path: string) { - const baseName = getBaseName(path); - if (!baseName || baseName === "." || baseName === "..") return false; - if (baseName.includes(".")) return true; - return EXTENSIONLESS_FILENAMES.has(baseName); +function isProviderPath(path: string): boolean { + return PROVIDER_SCHEMES.some((scheme) => path.toLowerCase().startsWith(`${scheme}://`)); } -function isAbsoluteLikePath(path: string) { - return ( - path.startsWith("/") || - WINDOWS_ABSOLUTE_RE.test(path) || - /^remote:\/\//i.test(path) || - /^wsl:\/\//i.test(path) - ); +/** Small bounded cache with a short TTL that also shares in-flight requests. */ +class TtlCache { + private readonly entries = new Map }>(); + constructor(private readonly now: () => number) {} + + get(key: string, load: () => Promise): Promise { + const hit = this.entries.get(key); + if (hit && this.now() - hit.at < CACHE_TTL_MS) { + this.entries.delete(key); + this.entries.set(key, hit); // refresh LRU order + return hit.value; + } + const value = load(); + this.entries.delete(key); + this.entries.set(key, { at: this.now(), value }); + // A failed load must not be remembered. + value.catch(() => { + if (this.entries.get(key)?.value === value) this.entries.delete(key); + }); + while (this.entries.size > CACHE_LIMIT) { + this.entries.delete(this.entries.keys().next().value as string); + } + return value; + } } -function resolveProviderAbsolutePath(path: string, workspaceRoot: string | undefined) { - if (!path.startsWith("/") || !workspaceRoot) return null; - - const remoteRoot = parseRemotePath(workspaceRoot); - if (remoteRoot) return `remote://${remoteRoot.connectionId}${path}`; - - const wslRoot = parseWslPath(workspaceRoot); - if (wslRoot) return buildWslPath(wslRoot.distro, path); - - return null; +function splitParent(path: string): { parent: string; name: string } | null { + const index = path.lastIndexOf("/"); + if (index < 0) return null; + const name = path.slice(index + 1); + if (!name) return null; + const parent = path.slice(0, index); + return { parent: parent.endsWith("://") || parent.match(/^[a-z]+:\/\/[^/]+$/i) ? `${parent}/` : parent, name }; } -function resolveCandidatePath(path: string, workspaceRoot: string | undefined) { - if (UNSUPPORTED_URI_RE.test(path) && !SUPPORTED_URI_RE.test(path)) return null; - - const decodedPath = decodeFileUri(path); - if (!hasPathShape(decodedPath) || !hasFileNameShape(decodedPath)) return null; - const providerAbsolutePath = resolveProviderAbsolutePath(decodedPath, workspaceRoot); - if (providerAbsolutePath) return providerAbsolutePath; - if (isAbsoluteLikePath(decodedPath)) return decodedPath; - if (!workspaceRoot) return null; - - return joinPath(workspaceRoot, decodedPath.replace(/^\.\//, "")); +async function defaultListDirectory(provider: string): Promise { + const remote = parseRemotePath(provider); + if (remote) { + return invoke("ssh_read_directory", { + connectionId: remote.connectionId, + path: remote.remotePath, + }); + } + const wsl = parseWslPath(provider); + if (!wsl) return []; + return invoke("wsl_read_directory", { + distro: wsl.distro, + path: wsl.linuxPath, + }); } -export function parseTerminalFileLinks( - lineText: string, - workspaceRoot: string | undefined, -): TerminalFileLink[] { - const links: TerminalFileLink[] = []; - const tokenRe = /\S+/g; - let match: RegExpExecArray | null; - - while ((match = tokenRe.exec(lineText))) { - const token = trimToken(match[0], match.index); - if (!token.text) continue; - - const { path, line, column } = extractLineColumn(token.text); - const resolvedPath = resolveCandidatePath(path, workspaceRoot); - if (!resolvedPath) continue; - - links.push({ - text: token.text, - path: resolvedPath, - line, - column, - startIndex: token.startIndex, - endIndex: token.endIndex, +export const defaultTerminalLinkDeps: TerminalLinkDeps = { + now: () => performance.now(), + statFile: async (path) => { + try { + return (await stat(path)).isFile; + } catch { + return false; + } + }, + listDirectory: defaultListDirectory, + resolveTargets: async (texts, baseDirectory) => + ( + await invoke<{ targets: (CoreLinkTarget | null)[] }>("terminal.parseLinkTarget", { + texts, + baseDirectory, + homeDirectory: await getHomeDirectory(), + providerSchemes: PROVIDER_SCHEMES, + }) + ).targets, +}; + +/** + * Builds a link finder. Token boundaries (the hit regions) and existence checks stay here; + * Core decides what each token means. No extension list is consulted: a token is a link + * exactly when its target is a file. Local files are stat'ed; remote and WSL files are + * looked up in a listing of their parent directory, the same adapter the file tree uses. + * Results are cached per (working directory, line text) for a short time, and identical + * in-flight requests are shared, so hovering around one line does not repeat the checks. + */ +export function createTerminalFileLinkFinder(deps: TerminalLinkDeps) { + const lines = new TtlCache(deps.now); + const directories = new TtlCache(deps.now); + + async function exists(path: string): Promise { + if (!isProviderPath(path)) return deps.statFile(path); + const parts = splitParent(path); + if (!parts) return false; + try { + const entries = await directories.get(parts.parent, () => deps.listDirectory(parts.parent)); + return entries.some((entry) => entry.name === parts.name && !entry.is_dir); + } catch { + return false; + } + } + + async function compute(lineText: string, workspaceRoot: string | undefined) { + const tokens: Token[] = []; + for (const match of lineText.matchAll(/\S+/g)) { + const token = trimToken(match[0], match.index ?? 0); + if (token.text) tokens.push(token); + if (tokens.length === MAX_CANDIDATES_PER_LINE) break; + } + if (tokens.length === 0) return []; + const targets = await deps.resolveTargets( + tokens.map((token) => token.text), + workspaceRoot, + ); + const checks = tokens.map(async (token, index): Promise => { + const target = targets[index]; + // Web links are handled by the web-links addon. + if (target?.kind !== "file" || !target.explicit) return null; + if (!(await exists(target.path))) return null; + return { + ...token, + path: target.path, + line: target.line ?? undefined, + column: target.column ?? undefined, + }; }); + return (await Promise.all(checks)).filter((link): link is TerminalFileLink => link !== null); } - return links; + return (lineText: string, workspaceRoot: string | undefined) => + lines.get(`${workspaceRoot ?? ""}\u0000${lineText}`, () => compute(lineText, workspaceRoot)); } + +export const findTerminalFileLinks = createTerminalFileLinkFinder(defaultTerminalLinkDeps); diff --git a/windows/tauri/src/test-utils/keymap-context-plans.ts b/windows/tauri/src/test-utils/keymap-context-plans.ts index 6f00c9dec4..ceab1c4530 100644 --- a/windows/tauri/src/test-utils/keymap-context-plans.ts +++ b/windows/tauri/src/test-utils/keymap-context-plans.ts @@ -4,10 +4,15 @@ import fixture from "../../../../shared/fixtures/keymap/context-v1.json"; import type { ParsedContext } from "@/features/keymaps/utils/context"; /** Fixed Core-verified plans for routing tests; this helper never parses expressions. */ -export function installKeymapContextPlans() { +export function installKeymapContextPlans( + /** Answers other Core commands a test has proven separately; return undefined to defer. */ + answerOther?: (command: string, payload: unknown) => unknown, +) { const original = native.invoke; const plans: Record = fixture.bundledPlans; return spyOn(native, "invoke").mockImplementation(async (command: string, payload?: unknown): Promise => { + const other = answerOther?.(command, payload); + if (other !== undefined) return other as T; if (command !== "keymap.parseContext") return original(command, payload as Record); const expression = (payload as { expression: string }).expression; return {