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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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
}
}
54 changes: 54 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift
Original file line number Diff line number Diff line change
@@ -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<Response, CoreCallError> = executeResult(
command: "keymap.analyzeConflicts", payload: request)
return result.map(\.conflicts)
}
}
54 changes: 54 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustTerminalLinks.swift
Original file line number Diff line number Diff line change
@@ -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<Response, CoreCallError> = 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

This file was deleted.

16 changes: 0 additions & 16 deletions macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
90 changes: 90 additions & 0 deletions macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift
Original file line number Diff line number Diff line change
@@ -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<ConflictResponse, RustCoreBridge.CoreCallError> = 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")
}
}
Loading
Loading