From 58686b400bdfd55073ce7b9c526ef6af6ac58a25 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 18 Oct 2026 13:00:16 +0800 Subject: [PATCH] feat: Quick Open with more transports --- Frameworks/Resources/Localizable.xcstrings | 82 +++++++++++++++++ .../Strings/Connections.swift | 6 ++ .../Commands/AppCommandRegistry.swift | 53 +++++++---- TFMTests/QuickOpenTests.swift | 91 +++++++++++++++++++ 4 files changed, 212 insertions(+), 20 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 58740808..2261d029 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -17349,6 +17349,88 @@ } } }, + "Connect with Eternal Terminal · %@" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Eternal Terminal verbinden · %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter via Eternal Terminal · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminalで接続 · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eternal Terminal로 연결 · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Eternal Terminal连接·%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Eternal Terminal連線·%@" + } + } + } + }, + "Connect with Mosh · %@" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Mosh verbinden · %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter via Mosh · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moshで接続 · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mosh로 연결 · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Mosh连接·%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用Mosh連線·%@" + } + } + } + }, "Connect with SSH · %@" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindPresentation/Strings/Connections.swift b/Frameworks/TermindPresentation/Strings/Connections.swift index 7b4a4459..ca5912b8 100644 --- a/Frameworks/TermindPresentation/Strings/Connections.swift +++ b/Frameworks/TermindPresentation/Strings/Connections.swift @@ -134,6 +134,12 @@ extension AppStrings { public static func connectWithSsh(_ value: String) -> LocalizedStringResource { "Connect with SSH · \(value)" } + public static func connectWithMosh(_ value: String) -> LocalizedStringResource { + "Connect with Mosh · \(value)" + } + public static func connectWithEternalTerminal(_ value: String) -> LocalizedStringResource { + "Connect with Eternal Terminal · \(value)" + } public static func connectWithTelnet(_ value: String) -> LocalizedStringResource { "Connect with Telnet · \(value)" } diff --git a/TFM/Application/Commands/AppCommandRegistry.swift b/TFM/Application/Commands/AppCommandRegistry.swift index ef9a5894..94595d5e 100644 --- a/TFM/Application/Commands/AppCommandRegistry.swift +++ b/TFM/Application/Commands/AppCommandRegistry.swift @@ -192,18 +192,40 @@ final class AppCommandRegistry { private func hostCommands(_ host: Host) -> [AppCommandDescriptor] { let keywords = [host.host] + host.supportedTerminalProtocols.map { host.displayAddress(for: $0) } + host.tags - var commands: [AppCommandDescriptor] = [] + var protocols = host.supportedTerminalProtocols + if let preferred = host.preferredTerminalProtocol { + protocols.removeAll { $0 == preferred } + protocols.insert(preferred, at: 0) + } + var commands = protocols.map { transport in + let address = host.displayAddress(for: transport) + let action: HostConnectAction + let subtitle: LocalizedStringResource + switch transport { + case .ssh: + action = .ssh + subtitle = AppStrings.Connections.connectWithSsh(address) + case .mosh: + action = .mosh + subtitle = AppStrings.Connections.connectWithMosh(address) + case .eternalTerminal: + action = .eternalTerminal + subtitle = AppStrings.Connections.connectWithEternalTerminal(address) + case .telnet: + action = .telnet + subtitle = AppStrings.Connections.connectWithTelnet(address) + } + return AppCommandDescriptor( + id: "host.\(transport.rawValue).\(host.id.uuidString)", + title: host.name, + subtitle: String(localized: subtitle), + symbol: action.symbol, + section: .hosts, + keywords: keywords + [transport.rawValue, transport.displayName], + target: .connectHost(host.id, action.target) + ) + } if host.sshEnabled { - commands.append( - AppCommandDescriptor( - id: "host.ssh.\(host.id.uuidString)", - title: host.name, - subtitle: String(localized: AppStrings.Connections.connectWithSsh(host.displayAddress(for: .ssh))), - symbol: "apple.terminal", - section: .hosts, - keywords: keywords + ["ssh"], - target: .connectHost(host.id, .sshSession) - )) commands.append( AppCommandDescriptor( id: "host.sftp.\(host.id.uuidString)", @@ -215,15 +237,6 @@ final class AppCommandRegistry { target: .connectHost(host.id, .sftpChannel) )) } - if host.telnetEnabled { - commands.append( - AppCommandDescriptor( - id: "host.telnet.\(host.id.uuidString)", title: host.name, - subtitle: String( - localized: AppStrings.Connections.connectWithTelnet(host.displayAddress(for: .telnet))), - symbol: MenuSymbol.telnet, section: .hosts, keywords: keywords + ["telnet"], - target: .connectHost(host.id, .telnetSession))) - } return commands } diff --git a/TFMTests/QuickOpenTests.swift b/TFMTests/QuickOpenTests.swift index bbd20c8f..ec72b0e0 100644 --- a/TFMTests/QuickOpenTests.swift +++ b/TFMTests/QuickOpenTests.swift @@ -181,6 +181,97 @@ struct QuickOpenTests { #expect(window.workspaces[0].panes.map(\.hostID) == [ssh.id, telnet.id]) } + @Test(arguments: [HostTerminalProtocol.ssh, .mosh, .eternalTerminal]) + func quickOpenPrioritizesAndOpensTheDefaultTransport(transport: HostTerminalProtocol) throws { + var host = Host(name: "Production", host: "server.invalid") + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.terminalTransports.defaultTransport = transport + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + defer { app.shutdownForTermination() } + let window = WindowModel() + let results = QuickOpenResults(commands: app.commands.commands(app: app, window: window), query: host.name) + let first = try #require(results.commands.first) + + #expect(first.symbol == transport.symbol) + app.commands.perform(first.target, app: app, window: window) + let session = try #require(window.selectedWorkspace?.focusedPane.sshSession) + #expect(session.hostID == host.id) + #expect(session.transport == transport) + + for (target, expected) in [ + (HostOpenTarget.sshSession, HostTerminalProtocol.ssh), + (.moshSession, .mosh), + (.eternalTerminalSession, .eternalTerminal), + ] { + let command = try #require(results.commands.first { $0.target == .connectHost(host.id, target) }) + app.commands.perform(command.target, app: app, window: window) + #expect(window.selectedWorkspace?.focusedPane.sshSession?.transport == expected) + } + } + + @Test(arguments: [false, true], [false, true]) + func quickOpenListsOnlyEnabledResumableTransports(moshEnabled: Bool, eternalTerminalEnabled: Bool) throws { + var host = Host(name: "Production", host: "server.invalid", port: 2222, tags: ["critical"]) + host.terminalTransports.moshEnabled = moshEnabled + host.terminalTransports.eternalTerminalEnabled = eternalTerminalEnabled + host.terminalTransports.eternalTerminalPort = 3022 + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + defer { app.shutdownForTermination() } + let commands = app.commands.commands(app: app, window: WindowModel()).filter { $0.section == .hosts } + + #expect(commands.contains { $0.target == .connectHost(host.id, .moshSession) } == moshEnabled) + #expect( + commands.contains { $0.target == .connectHost(host.id, .eternalTerminalSession) } == eternalTerminalEnabled) + #expect(commands.contains { $0.target == .connectHost(host.id, .sftpChannel) }) + #expect(Set(commands.map(\.id)).count == commands.count) + #expect(commands.allSatisfy { $0.matches("critical") }) + if moshEnabled { + let mosh = try #require(commands.first { $0.target == .connectHost(host.id, .moshSession) }) + #expect(mosh.symbol == MenuSymbol.mosh) + #expect(mosh.matches("Mosh")) + #expect(mosh.subtitle == String(localized: AppStrings.Connections.connectWithMosh("server.invalid:2222"))) + } + if eternalTerminalEnabled { + let et = try #require(commands.first { $0.target == .connectHost(host.id, .eternalTerminalSession) }) + #expect(et.symbol == MenuSymbol.eternalTerminal) + #expect(et.matches("Eternal Terminal")) + #expect( + et.subtitle + == String(localized: AppStrings.Connections.connectWithEternalTerminal("server.invalid:3022"))) + } + } + + @Test + func quickOpenFallsBackWhenTheDefaultTransportIsUnavailable() { + func targets(for host: Host) -> [AppCommandTarget] { + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + defer { app.shutdownForTermination() } + return app.commands.commands(app: app, window: WindowModel()) + .filter { $0.section == .hosts }.map(\.target) + } + var host = Host(name: "Production", host: "server.invalid") + host.terminalTransports.defaultTransport = .mosh + + #expect(targets(for: host) == [.connectHost(host.id, .sshSession), .connectHost(host.id, .sftpChannel)]) + + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.tailscale = .init(groupID: UUID(), stableNodeID: "quick-open-test") + #expect( + targets(for: host) == [ + .connectHost(host.id, .sshSession), .connectHost(host.id, .eternalTerminalSession), + .connectHost(host.id, .sftpChannel), + ]) + + host.sshEnabled = false + host.telnetEnabled = true + #expect(targets(for: host) == [.connectHost(host.id, .telnetSession)]) + + host.telnetEnabled = false + #expect(targets(for: host).isEmpty) + } + @Test func registryCombinesManagementWorkspacesHostsAndActions() { let host = Host(name: "Production", host: "prod.example", tags: ["critical"])