diff --git a/FrameworkTests/RemoteShellHistoryLoaderTests.swift b/FrameworkTests/RemoteShellHistoryLoaderTests.swift new file mode 100644 index 00000000..2e22951d --- /dev/null +++ b/FrameworkTests/RemoteShellHistoryLoaderTests.swift @@ -0,0 +1,108 @@ +#if os(macOS) + import Foundation + import TermindApplication + import TermindDomain + import Testing + + @testable import TermindRemoteInfrastructure + + @Suite + struct RemoteShellHistoryLoaderTests { + @Test func execReadsQuotedPathsAsDataAndWorksWithoutIntegration() async throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: folder) } + let file = folder.appendingPathComponent("history ' quoted; $(false)") + try Data("pwd\necho $(false)\n".utf8).write(to: file) + let loader = RemoteShellHistoryLoader( + charset: .utf8, resolveSource: { nil }, + execute: { command, _ in + try Self.execute(command, environment: ["SHELL": "/bin/bash", "HISTFILE": file.path]) + }) + let standard = try await loader.load(source: nil) + let custom = try await loader.load(source: .init(shell: .bash, path: file.path)) + #expect(standard.map(\.command) == ["pwd", "echo $(false)"]) + #expect(custom == standard) + #expect(standard.allSatisfy { $0.executedAt == nil }) + #expect(try String(contentsOf: file, encoding: .utf8) == "pwd\necho $(false)\n") + } + + @Test func readsOnlyBoundedTailAndKeepsCompleteRecentRecords() async throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + var content = Data(String(repeating: "x", count: RemoteShellHistoryLoader.maximumFileBytes + 100).utf8) + content.append(Data("\n#123\npwd\n#124\nwhoami\n".utf8)) + try content.write(to: file) + let loader = RemoteShellHistoryLoader( + charset: .utf8, resolveSource: { nil }, + execute: { command, _ in + try Self.execute(command) + }) + let records = try await loader.load(source: .init(shell: .bash, path: file.path)) + #expect(records.map(\.command) == ["pwd", "whoami"]) + } + + @Test func missingAndExplicitlyDisabledFilesAreSuccessfulEmptyReads() async throws { + let loader = RemoteShellHistoryLoader( + charset: .utf8, resolveSource: { nil }, + execute: { command, _ in + try Self.execute(command) + }) + #expect(try await loader.load(source: .init(shell: .zsh, path: "/nonexistent-\(UUID())")).isEmpty) + #expect(try await loader.load(source: .init(shell: .bash, path: "")).isEmpty) + } + + @Test func zshMetaficationAndRemoteCharsetAreDecodedBeforeParsing() async throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + let bytes = try RemoteCharsetStreamConverter.encode(": 123:0;echo 中文\n", charset: .gb18030) + var metafied = Data() + for byte in bytes { + if byte >= 0x80 { + metafied.append(0x83) + metafied.append(byte ^ 0x20) + } else { + metafied.append(byte) + } + } + try metafied.write(to: file) + let loader = RemoteShellHistoryLoader( + charset: .gb18030, resolveSource: { nil }, + execute: { command, _ in + try Self.execute(command) + }) + #expect(try await loader.load(source: .init(shell: .zsh, path: file.path)).map(\.command) == ["echo 中文"]) + } + + @Test func execFailureDoesNotReturnAPartialSnapshot() async { + let loader = RemoteShellHistoryLoader( + charset: .utf8, resolveSource: { nil }, + execute: { _, _ in + SSHExecResult( + stdout: "partial", stderr: "denied", exitCode: 1, stdoutTruncated: false, stderrTruncated: false + ) + }) + await #expect(throws: (any Error).self) { try await loader.load(source: nil) } + } + + private nonisolated static func execute(_ command: String, environment: [String: String] = [:]) throws + -> SSHExecResult + { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, replacement in + replacement + } + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + let bytes = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return SSHExecResult( + stdout: String(decoding: bytes, as: UTF8.self), stderr: "", exitCode: process.terminationStatus, + stdoutTruncated: false, stderrTruncated: false) + } + } +#endif diff --git a/FrameworkTests/SFTPHistoryTests.swift b/FrameworkTests/SFTPHistoryTests.swift index ba3308cd..013d7a0d 100644 --- a/FrameworkTests/SFTPHistoryTests.swift +++ b/FrameworkTests/SFTPHistoryTests.swift @@ -1,108 +1,43 @@ import Foundation -import SwiftData import TermindApplication import TermindDomain -import TermindPersistence import Testing -@Suite -@MainActor +@Suite @MainActor struct SFTPHistoryTests { - @Test func memoryStoreNormalizesDeduplicatesSearchesAndScopesByHost() throws { - let firstHost = UUID() - let secondHost = UUID() + @Test func visitsAreNormalizedDeduplicatedAndScopedToSession() { + let first = SFTPHistoryModel() + let second = SFTPHistoryModel() + first.record(kind: .directory, path: "/srv/app/") + first.record(kind: .file, path: "/srv/app/config.json") + first.record(kind: .directory, path: "/srv/app") + second.record(kind: .directory, path: "/var/log") + #expect(first.entries().map(\.path) == ["/srv/app", "/srv/app/config.json"]) + #expect(first.entries(matching: "CONFIG").map(\.kind) == [.file]) + #expect(second.entries().map(\.path) == ["/var/log"]) + first.close() + #expect(first.entries().isEmpty) + #expect(second.entries().count == 1) + } + + @Test func closedHistoryRejectsLateTransferCallbacks() { + let history = SFTPHistoryModel() + history.record(kind: .file, path: "/before") + history.close() + history.record(kind: .file, path: "/late") + #expect(history.entries().isEmpty) + let reopened = SFTPHistoryModel() + #expect(reopened.entries().isEmpty) + #expect(reopened.sessionID != history.sessionID) + } + + @Test func trimRetainsNewestPaths() throws { let model = SFTPHistoryModel() - - model.record( - kind: .directory, path: "/srv/../srv/app", for: firstHost, - at: Date(timeIntervalSince1970: 1) - ) - model.record( - kind: .file, path: "/srv/app/config.json", for: firstHost, - at: Date(timeIntervalSince1970: 2) - ) - model.record( - kind: .directory, path: "/srv/app", for: firstHost, - at: Date(timeIntervalSince1970: 3) - ) - model.record( - kind: .directory, path: "/var/log", for: secondHost, - at: Date(timeIntervalSince1970: 4) - ) - - #expect(model.entries(for: firstHost).map(\.kind) == [.directory, .file]) - #expect(model.entries(for: firstHost).map(\.path) == ["/srv/app", "/srv/app/config.json"]) - #expect(model.entries(for: firstHost, matching: "CONFIG").map(\.kind) == [.file]) - #expect(model.entries(for: secondHost).map(\.path) == ["/var/log"]) - } - - @Test func memoryStoreTrimsDeletesAndClearsPerHost() throws { - let hostID = UUID() - let otherHostID = UUID() - let model = SFTPHistoryModel() - - for index in 0...SFTPHistoryModel.perHostLimit { - model.record( - kind: .directory, path: "/directory-\(index)", for: hostID, - at: Date(timeIntervalSince1970: TimeInterval(index)) - ) + for index in 0...SFTPHistoryModel.perSessionLimit { + model.record(kind: .directory, path: "/directory-\(index)") } - model.record(kind: .file, path: "/keep.txt", for: otherHostID) - - let entries = model.entries(for: hostID) - #expect(entries.count == SFTPHistoryModel.perHostLimit) - #expect(entries.first?.path == "/directory-\(HistoryLimits.retainedEntries)") - #expect(entries.last?.path == "/directory-1") - - let removed = try #require(entries.first) - model.delete(removed.id) - #expect(!model.entries(for: hostID).contains { $0.id == removed.id }) - model.clear(for: hostID) - #expect(model.entries(for: hostID).isEmpty) - #expect(model.entries(for: otherHostID).map(\.path) == ["/keep.txt"]) - } - - @Test func loadedSFTPHistoryImmediatelyTrimsEveryHostToTheSharedLimit() { - let hosts = [UUID(), UUID()] - let entries = hosts.flatMap { hostID in - (0...HistoryLimits.retainedEntries).map { index in - SFTPHistoryEntry( - hostID: hostID, kind: .directory, path: "/directory-\(index)", - visitedAt: Date(timeIntervalSince1970: TimeInterval(index)) - ) - } - } - - let model = SFTPHistoryModel(entries: entries) - - for hostID in hosts { - let retained = model.entries(for: hostID) - #expect(retained.count == HistoryLimits.retainedEntries) - #expect(retained.first?.path == "/directory-\(HistoryLimits.retainedEntries)") - #expect(retained.last?.path == "/directory-1") - } - } - - @Test func swiftDataRepositoryPersistsAndClearsPerHost() throws { - let container = try TermindPersistence.makeContainer(inMemory: true) - let hostID = UUID() - let otherHostID = UUID() - let model = SFTPHistoryModel( - repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container)) - ) - - model.record(kind: .directory, path: "/var/log", for: hostID) - model.record(kind: .file, path: "/tmp/a.txt", for: otherHostID) - #expect(model.storageError == nil) - - let reloaded = SFTPHistoryModel( - repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container)) - ) - #expect(reloaded.entries(for: hostID).map(\.path) == ["/var/log"]) - #expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"]) - - reloaded.clear(for: hostID) - #expect(reloaded.entries(for: hostID).isEmpty) - #expect(reloaded.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"]) + let values = model.entries() + #expect(values.count == SFTPHistoryModel.perSessionLimit) + #expect(values.last?.path == "/directory-1") } } diff --git a/FrameworkTests/SessionHistoryTests.swift b/FrameworkTests/SessionHistoryTests.swift new file mode 100644 index 00000000..8f4a291a --- /dev/null +++ b/FrameworkTests/SessionHistoryTests.swift @@ -0,0 +1,214 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import TermindPresentation +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite @MainActor +struct SessionHistoryTests { + @Test func snapshotDoesNotReplaceLiveCommandsAndLoadsOnlyOnce() async throws { + let gate = HistoryReadGate() + let model = CommandHistoryModel() + model.load(using: gate, source: nil) + await gate.waitForRead() + let live = try #require(model.record("pwd", at: Date(timeIntervalSince1970: 12.5))) + model.recordCompletion(for: live.id, exitStatus: 0, duration: 0.25) + await gate.finish([ + .init(command: "pwd", executedAt: Date(timeIntervalSince1970: 10)), + .init(command: "old without time"), + .init(command: "pwd", executedAt: Date(timeIntervalSince1970: 12)), + ]) + #expect(await settles { model.hasLoaded }) + let entries = model.entries() + #expect(entries.map(\.command) == ["pwd", "old without time", "pwd"]) + #expect(entries.first?.id == live.id) + #expect(entries.first?.exitStatus == 0) + #expect(entries.first?.duration == 0.25) + model.cancelLoading() + model.load(using: gate, source: nil) + #expect(await gate.readCount == 1) + } + + @Test func cancelledOrClosedReadsCannotPublishAndReconnectCanRetry() async { + let stale = HistoryReadGate() + let current = HistoryReadGate() + let model = CommandHistoryModel() + model.record("live") + model.load(using: stale, source: nil) + await stale.waitForRead() + model.cancelLoading() + model.load(using: current, source: nil) + await current.waitForRead() + await stale.finish([.init(command: "stale")]) + await current.finish([.init(command: "current")]) + #expect(await settles { model.hasLoaded }) + #expect(model.entries().map(\.command) == ["live", "current"]) + + let closing = HistoryReadGate() + let closed = CommandHistoryModel() + closed.load(using: closing, source: nil) + await closing.waitForRead() + closed.close() + await closing.finish([.init(command: "too late")]) + for _ in 0..<20 { await Task.yield() } + #expect(closed.entries().isEmpty) + #expect(closed.record("callback") == nil) + #expect(!closed.hasLoaded) + } + + @Test func failureKeepsLiveRecordsAndAnEmptySuccessfulReadStopsRetries() async { + let model = CommandHistoryModel() + model.record("retained") + model.load(using: FailedHistoryRead(), source: nil) + #expect(await settles { model.loadError != nil }) + #expect(model.entries().map(\.command) == ["retained"]) + let empty = HistoryReadGate() + model.load(using: empty, source: nil) + await empty.waitForRead() + await empty.finish([]) + #expect(await settles { model.hasLoaded }) + #expect(model.loadError == nil) + } + + @Test func unknownTimeHasNoSubtitleButKnownDurationStillDisplays() { + var entry = CommandHistoryEntry(sessionID: UUID(), command: "pwd") + #expect(CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty) + entry.duration = 2 + #expect(!CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty) + } + + private func settles(_ predicate: () -> Bool) async -> Bool { + for _ in 0..<1000 { + if predicate() { return true } + await Task.yield() + } + return predicate() + } +} + +private actor HistoryReadGate: ShellHistoryLoading { + private var continuation: CheckedContinuation<[ShellHistoryRecord], Never>? + private(set) var readCount = 0 + func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { + readCount += 1 + return await withCheckedContinuation { continuation = $0 } + } + func waitForRead() async { + while continuation == nil { await Task.yield() } + } + func finish(_ records: [ShellHistoryRecord]) { + continuation?.resume(returning: records) + continuation = nil + } +} + +private struct FailedHistoryRead: ShellHistoryLoading { + func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { + throw CocoaError(.fileReadNoPermission) + } +} + +@Suite +struct ShellHistoryParsingTests { + @Test func bashPreservesRepeatedCommandsAndTimestampedMultilineRecords() { + let records = ShellHistoryParser.parse("#100\nprintf 'one\ntwo'\n#101\npwd\n#102\npwd\n", shell: .bash) + #expect(records.map(\.command) == ["printf 'one\ntwo'", "pwd", "pwd"]) + #expect(records.map { $0.executedAt?.timeIntervalSince1970 } == [100, 101, 102]) + let plain = ShellHistoryParser.parse("pwd\npwd\npartial", shell: .bash) + #expect(plain.map(\.command) == ["pwd", "pwd"]) + #expect(plain.allSatisfy { $0.executedAt == nil }) + } + + @Test func zshAndFishDecodeTheirOwnMultilineFormats() { + let zsh = ShellHistoryParser.parse(": 100:2;printf 'one\\\ntwo'\n: 103:0;pwd\n", shell: .zsh) + #expect(zsh.map(\.command) == ["printf 'one\ntwo'", "pwd"]) + #expect(zsh.first?.duration == 2) + let fish = ShellHistoryParser.parse( + "- cmd: printf one\\ntwo\\\\three\n when: 100\n paths:\n - /tmp\n- cmd: pwd\n", shell: .fish) + #expect(fish.map(\.command) == ["printf one\ntwo\\three", "pwd"]) + #expect(fish.first?.executedAt?.timeIntervalSince1970 == 100) + #expect(fish.last?.executedAt == nil) + } + + @Test func truncatedRecordsDoNotTurnIntoCommands() { + #expect( + ShellHistoryParser.parse("broken\nremaining\n#123\npwd\n", shell: .bash, truncatedStart: true) + .map(\.command) == ["pwd"]) + #expect( + ShellHistoryParser.parse("broken\nremaining\n: 123:0;pwd\n", shell: .zsh, truncatedStart: true) + .map(\.command) == ["pwd"]) + #expect( + ShellHistoryParser.parse("broken\n when: 123\n- cmd: pwd\n", shell: .fish, truncatedStart: true) + .map(\.command) == ["pwd"]) + } + + @Test(arguments: ["bash", "zsh", "fish"]) + func realInjectedScriptsAreFilteredWithoutHidingUserText(shell: String) throws { + var bootstrap = RemoteShellIntegrationBootstrap(token: "OLD123") + #expect(ShellHistoryFilter.isIntegrationCommand(String(decoding: bootstrap.startCommand, as: UTF8.self))) + let update = bootstrap.receive(Data("__TRMD_OLD123_SHELL__/bin/\(shell)__\n".utf8)) + let install = String(decoding: try #require(update.command), as: UTF8.self) + #expect(ShellHistoryFilter.isIntegrationCommand(install)) + #expect(ShellHistoryFilter.isIntegrationCommand("__termind_report_prompt")) + #expect(!ShellHistoryFilter.isIntegrationCommand("echo __termind_report_prompt")) + #expect(!ShellHistoryFilter.isIntegrationCommand("grep '__TRMD_OLD123_PROBE__' log")) + #expect(!ShellHistoryFilter.isIntegrationCommand("npm run integration")) + } + + @Test func bootstrapCarriesExplicitCustomOrDisabledHistoryPaths() throws { + for path in ["%2Fhome%2Fuser%2Fcustom%20history", ""] { + var bootstrap = RemoteShellIntegrationBootstrap(token: "SOURCE") + _ = bootstrap.receive(Data("__TRMD_SOURCE_SHELL__/bin/bash__\n".utf8)) + let result = bootstrap.receive(Data("__TRMD_SOURCE_HISTORY__\(path)\n__TRMD_SOURCE_DONE__\n".utf8)) + #expect(result.historySource?.shell == .bash) + #expect(result.historySource?.path == (path.isEmpty ? "" : "/home/user/custom history")) + } + } +} + +@Suite @MainActor +struct RetiredSessionHistoryMigrationTests { + @Test(arguments: [9, 48]) + func oldStoresDropSessionHistoryAndRetainOtherDataAfterReopening(version: Int) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("History.store") + let group = HostGroup(name: "Keep this group") + try writeLegacyStore(version: version, url: url, group: group) + for _ in 0..<2 { + let container = try TermindPersistence.makeContainer(url: url) + let names = container.schema.entities.map(\.name) + #expect(!names.contains("PersistentCommandHistoryEntry")) + #expect(!names.contains("PersistentSFTPHistoryEntry")) + #expect(try container.mainContext.fetch(FetchDescriptor()).map(\.value) == [group]) + } + } + + private func writeLegacyStore(version: Int, url: URL, group: HostGroup) throws { + let schema = + version == 9 + ? Schema(versionedSchema: TermindSchemaV9.self) : Schema(versionedSchema: TermindSchemaV48.self) + let container = try ModelContainer( + for: schema, + configurations: [ + ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none) + ]) + container.mainContext.insert(PersistentHostGroup(group)) + if version == 9 { + container.mainContext.insert( + TermindSchemaV9Models.PersistentCommandHistoryEntry( + hostID: UUID(), command: "old", executedAt: Date())) + } else { + container.mainContext.insert( + PersistentCommandHistoryEntry(hostID: UUID(), command: "old", executedAt: Date())) + } + container.mainContext.insert( + PersistentSFTPHistoryEntry(hostID: UUID(), kind: "directory", path: "/old", visitedAt: Date())) + try container.mainContext.save() + } +} diff --git a/FrameworkTests/SettingsPortTests.swift b/FrameworkTests/SettingsPortTests.swift index 1de4b05a..967654a9 100644 --- a/FrameworkTests/SettingsPortTests.swift +++ b/FrameworkTests/SettingsPortTests.swift @@ -158,38 +158,17 @@ struct SettingsPortTests { @Test func historyRetainsTheNewestSharedLimitAndUnknownCompletion() { let model = CommandHistoryModel() - let host = UUID() - #expect(model.record(" \n\t ", for: host) == nil) - for index in 0...CommandHistoryModel.perHostLimit { - model.record("command \(index)", for: host, at: Date(timeIntervalSince1970: Double(index))) + #expect(model.record(" \n\t ") == nil) + for index in 0...CommandHistoryModel.perSessionLimit { + model.record("command \(index)") } - let values = model.entries(for: host) - #expect(values.count == CommandHistoryModel.perHostLimit) + let values = model.entries() + #expect(values.count == CommandHistoryModel.perSessionLimit) #expect(values.first?.command == "command \(HistoryLimits.retainedEntries)") #expect(values.last?.command == "command 1") #expect(values.allSatisfy { $0.exitStatus == nil && $0.duration == nil }) } - @Test func loadedCommandHistoryImmediatelyTrimsEveryHostToTheSharedLimit() { - let hosts = [UUID(), UUID()] - let entries = hosts.flatMap { hostID in - (0...HistoryLimits.retainedEntries).map { index in - CommandHistoryEntry( - hostID: hostID, command: "command \(index)", - executedAt: Date(timeIntervalSince1970: TimeInterval(index)) - ) - } - } - - let model = CommandHistoryModel(entries: entries) - - for hostID in hosts { - let retained = model.entries(for: hostID) - #expect(retained.count == HistoryLimits.retainedEntries) - #expect(retained.first?.command == "command \(HistoryLimits.retainedEntries)") - #expect(retained.last?.command == "command 1") - } - } } @MainActor private final class FailingTerminalPreferences: TerminalPreferencesPersisting { diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7685134b..87ec23e2 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -5406,23 +5406,6 @@ } } }, - "Clear File History for this Host?" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除此主机的文件历史?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除此主機的檔案歷史?" - } - } - } - }, "Clear Filter" : { "extractionState" : "manual", "localizations" : { @@ -5491,23 +5474,6 @@ } } }, - "Clear History?" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清空历史记录?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除歷史?" - } - } - } - }, "Clear Key" : { "extractionState" : "manual", "localizations" : { @@ -5576,23 +5542,6 @@ } } }, - "Clear command history for this Host?" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除此主机的命令历史?" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除此主機的指令歷史?" - } - } - } - }, "Clear connection history?" : { "comment" : "A confirmation dialog asking the user to clear their connection history.", "extractionState" : "manual", @@ -6312,23 +6261,6 @@ } } }, - "Commands confirmed by the remote shell appear here." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "远程shell确认的命令会显示在这里。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "遠端shell確認的指令會顯示在這裡。" - } - } - } - }, "Communication" : { "extractionState" : "manual", "localizations" : { @@ -10856,6 +10788,29 @@ } } }, + "Enable Shell Integration to add commands to this session’s history in real time." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Shell Integration to add commands to this session’s history in real time." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用Shell集成以实时添加当前会话的命令历史。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用Shell整合以即時加入目前工作階段的指令歷史。" + } + } + } + }, "Enable Web Search" : { "extractionState" : "manual", "localizations" : { @@ -25630,6 +25585,29 @@ } } }, + "Shell history and commands from this session appear here." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell history and commands from this session appear here." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell历史和当前会话的命令会显示在这里。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell歷史和目前工作階段的指令會顯示在這裡。" + } + } + } + }, "Shift" : { "extractionState" : "manual", "localizations" : { @@ -31135,23 +31113,6 @@ } } }, - "This permanently removes all saved commands for the current Host." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这将永久删除当前主机所有保存的命令。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "這將永久刪除目前主機所有儲存的指令。" - } - } - } - }, "This permanently removes connection, command, and File History without deleting the Hosts." : { "extractionState" : "manual", "localizations" : { @@ -31254,23 +31215,6 @@ } } }, - "This session is not recording history. Enable Shell Integration and use bash, zsh, or fish." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此会话未记录历史。请启用Shell集成,并使用bash、zsh或fish。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "此工作階段未記錄歷史。請啟用Shell整合,並使用bash、zsh或fish。" - } - } - } - }, "This settings record requires sync format %lld." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift index 1c846b8c..2052d888 100644 --- a/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift @@ -1,112 +1,128 @@ import Foundation import TermindDomain -import struct TermindDomain.Host - -/// Incremental persistence boundary for terminal history. -/// -/// Preview and focused-test instances use the in-memory projection. The app -/// supplies its SwiftData context so inserts and deletes touch only history -/// rows, independently of HostLibraryModel's compatibility snapshot persistence. +/// One terminal's memory-only file snapshot and subsequent shell events. @MainActor @Observable public final class CommandHistoryModel { - public static let perHostLimit = HistoryLimits.retainedEntries - - private let repository: any CommandHistoryRepository + public static let perSessionLimit = HistoryLimits.retainedEntries + public let sessionID: UUID public private(set) var revision: UInt64 = 0 - public private(set) var storageError: String? + public private(set) var loadError: String? + public private(set) var isLoading = false + public private(set) var hasLoaded = false + private var values: [CommandHistoryEntry] + private var isClosed = false + private var loadTask: Task? + private var loadID: UUID? - public init( - entries: [CommandHistoryEntry] = [], - repository: (any CommandHistoryRepository)? = nil - ) { - self.repository = repository ?? InMemoryCommandHistoryRepository(entries: entries) - do { - try self.repository.trim(to: Self.perHostLimit) - } catch { - storageError = error.localizedDescription - } - } - - public func entries(for hostID: Host.ID, matching query: String = "") -> [CommandHistoryEntry] { - let values: [CommandHistoryEntry] - do { - values = try repository.entries(for: hostID) - storageError = nil - } catch { - storageError = error.localizedDescription - return [] - } - let normalized = values.map { entry in - var entry = entry + public init(sessionID: UUID = UUID(), entries: [CommandHistoryEntry] = []) { + self.sessionID = sessionID + values = Array(entries.filter { $0.sessionID == sessionID }.prefix(Self.perSessionLimit)).map { + var entry = $0 entry.command = CommandHistoryEntry.normalizingLineContinuations(in: entry.command) return entry } - guard !query.isEmpty else { return normalized } - return normalized.filter { $0.command.localizedCaseInsensitiveContains(query) } + } + + public func entries(matching query: String = "") -> [CommandHistoryEntry] { + query.isEmpty ? values : values.filter { $0.command.localizedCaseInsensitiveContains(query) } } @discardableResult - public func record( - _ command: String, for hostID: Host.ID, at date: Date = Date() - ) -> CommandHistoryEntry? { + public func record(_ command: String, at date: Date = Date()) -> CommandHistoryEntry? { let command = CommandHistoryEntry.normalizingLineContinuations(in: command) - guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return nil - } - let entry = CommandHistoryEntry(hostID: hostID, command: command, executedAt: date) - do { - try repository.insert(entry, limit: Self.perHostLimit) - storageError = nil - } catch { - storageError = error.localizedDescription - return nil - } + guard !isClosed, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !ShellHistoryFilter.isIntegrationCommand(command) + else { return nil } + let entry = CommandHistoryEntry(sessionID: sessionID, command: command, executedAt: date) + values.insert(entry, at: 0) + trim() revision &+= 1 return entry } @discardableResult public func recordCompletion( - for id: CommandHistoryEntry.ID, hostID: Host.ID, - exitStatus: Int32, duration: TimeInterval? + for id: CommandHistoryEntry.ID, exitStatus: Int32, duration: TimeInterval? ) -> CommandHistoryEntry? { - do { - guard var entry = try repository.entries(for: hostID).first(where: { $0.id == id }) + guard !isClosed, let index = values.firstIndex(where: { $0.id == id }) else { return nil } + values[index].exitStatus = exitStatus + values[index].duration = duration.flatMap { $0.isFinite ? max(0, $0) : nil } + revision &+= 1 + return values[index] + } + + public func load(using loader: any ShellHistoryLoading, source: ShellHistorySource?) { + guard !isClosed, !hasLoaded, loadTask == nil else { return } + let token = UUID() + loadID = token + isLoading = true + loadError = nil + loadTask = Task { [weak self] in + do { + let snapshot = try await loader.load(source: source) + guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return } + self.merge(snapshot) + self.hasLoaded = true + self.finishLoading() + } catch { + guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return } + self.loadError = error.localizedDescription + self.finishLoading() + } + } + } + + /// Disconnect cancels only the current read; reconnect retains a completed snapshot. + public func cancelLoading() { + loadID = nil + loadTask?.cancel() + finishLoading() + } + + /// Closing is terminal, including while a closed pane remains in the reopen stack. + public func close() { + isClosed = true + cancelLoading() + values.removeAll() + loadError = nil + revision &+= 1 + } + + private func finishLoading() { + loadTask = nil + isLoading = false + } + + private func merge(_ snapshot: [ShellHistoryRecord]) { + // Consume matches one at a time: repeated executions must not collapse into a set. + var unmatched = values + let imported = snapshot.reversed().compactMap { record -> CommandHistoryEntry? in + let command = CommandHistoryEntry.normalizingLineContinuations(in: record.command) + guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !ShellHistoryFilter.isIntegrationCommand(command) else { return nil } - entry.exitStatus = exitStatus - entry.duration = duration.map { max(0, $0) } - try repository.update(entry) - storageError = nil - revision &+= 1 - return entry - } catch { - storageError = error.localizedDescription - return nil - } - } - - public func delete(_ id: CommandHistoryEntry.ID) { - do { - try repository.delete(id) - storageError = nil - } catch { - storageError = error.localizedDescription - return + if let date = record.executedAt, + let index = unmatched.firstIndex(where: { + $0.command == command + && $0.executedAt.map { + floor($0.timeIntervalSince1970) == floor(date.timeIntervalSince1970) + } == true + }) + { + unmatched.remove(at: index) + return nil + } + return CommandHistoryEntry( + sessionID: sessionID, command: command, executedAt: record.executedAt, duration: record.duration) } + values.append(contentsOf: imported) + trim() revision &+= 1 } - public func clear(for hostID: Host.ID) { - do { - try repository.clear(for: hostID) - storageError = nil - } catch { - storageError = error.localizedDescription - return - } - revision &+= 1 + private func trim() { + if values.count > Self.perSessionLimit { values.removeLast(values.count - Self.perSessionLimit) } } - } diff --git a/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift index 89b8ee8c..a6281d4f 100644 --- a/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SFTPHistoryModel.swift @@ -1,81 +1,37 @@ import Foundation import TermindDomain -import struct TermindDomain.Host - -/// Incremental persistence boundary for SFTP browse history. -/// -/// Preview and focused-test instances use the in-memory projection. Each app -/// supplies a persistent repository so successful directory visits and file -/// downloads remain available across sessions without entering Vault sync. +/// Successful browse/open operations from one live SFTP session only. @MainActor @Observable public final class SFTPHistoryModel { - public static let perHostLimit = HistoryLimits.retainedEntries - - private let repository: any SFTPHistoryRepository + public static let perSessionLimit = HistoryLimits.retainedEntries + public let sessionID: UUID public private(set) var revision: UInt64 = 0 - public private(set) var storageError: String? + private var values: [SFTPHistoryEntry] + private var isClosed = false - public init( - entries: [SFTPHistoryEntry] = [], - repository: (any SFTPHistoryRepository)? = nil - ) { - self.repository = repository ?? InMemorySFTPHistoryRepository(entries: entries) - do { - try self.repository.trim(to: Self.perHostLimit) - } catch { - storageError = error.localizedDescription - } + public init(sessionID: UUID = UUID(), entries: [SFTPHistoryEntry] = []) { + self.sessionID = sessionID + values = Array(entries.filter { $0.sessionID == sessionID }.prefix(Self.perSessionLimit)) } - public func entries(for hostID: Host.ID, matching query: String = "") -> [SFTPHistoryEntry] { - let values: [SFTPHistoryEntry] - do { - values = try repository.entries(for: hostID) - storageError = nil - } catch { - storageError = error.localizedDescription - return [] - } - guard !query.isEmpty else { return values } - return values.filter { $0.path.localizedCaseInsensitiveContains(query) } + public func entries(matching query: String = "") -> [SFTPHistoryEntry] { + query.isEmpty ? values : values.filter { $0.path.localizedCaseInsensitiveContains(query) } } - public func record( - kind: SFTPHistoryEntry.Kind, path: String, for hostID: Host.ID, - at date: Date = Date() - ) { + public func record(kind: SFTPHistoryEntry.Kind, path: String, at date: Date = Date()) { + guard !isClosed else { return } let path = RemotePath.normalized(path) - do { - try repository.upsert( - SFTPHistoryEntry(hostID: hostID, kind: kind, path: path, visitedAt: date), - limit: Self.perHostLimit - ) - storageError = nil - revision &+= 1 - } catch { - storageError = error.localizedDescription - } + values.removeAll { $0.kind == kind && $0.path == path } + values.insert(SFTPHistoryEntry(sessionID: sessionID, kind: kind, path: path, visitedAt: date), at: 0) + if values.count > Self.perSessionLimit { values.removeLast(values.count - Self.perSessionLimit) } + revision &+= 1 } - public func delete(_ id: SFTPHistoryEntry.ID) { - do { - try repository.delete(id) - storageError = nil - revision &+= 1 - } catch { - storageError = error.localizedDescription - } - } - - public func clear(for hostID: Host.ID) { - do { - try repository.clear(for: hostID) - storageError = nil - revision &+= 1 - } catch { - storageError = error.localizedDescription - } + public func close() { + isClosed = true + values.removeAll() + revision &+= 1 } } diff --git a/Frameworks/TermindApplication/HistoryLimits.swift b/Frameworks/TermindApplication/HistoryLimits.swift index 7b85df55..d1cbbaad 100644 --- a/Frameworks/TermindApplication/HistoryLimits.swift +++ b/Frameworks/TermindApplication/HistoryLimits.swift @@ -1,3 +1,3 @@ -public enum HistoryLimits { +public nonisolated enum HistoryLimits { public static let retainedEntries = 250 } diff --git a/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift b/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift deleted file mode 100644 index 03852e6e..00000000 --- a/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import TermindDomain - -import struct TermindDomain.Host - -@MainActor -public protocol CommandHistoryRepository: AnyObject { - func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] - func trim(to limit: Int) throws - func insert(_ entry: CommandHistoryEntry, limit: Int) throws - func update(_ entry: CommandHistoryEntry) throws - func delete(_ id: CommandHistoryEntry.ID) throws - func clear(for hostID: Host.ID) throws -} - -@MainActor -public final class InMemoryCommandHistoryRepository: CommandHistoryRepository { - private var values: [CommandHistoryEntry] - - public init(entries: [CommandHistoryEntry] = []) { values = entries } - - public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] { - values.filter { $0.hostID == hostID }.sorted { $0.executedAt > $1.executedAt } - } - - public func trim(to limit: Int) throws { - let entriesByHost = Dictionary(grouping: values, by: \.hostID) - let retained = Set( - entriesByHost.values.flatMap { entries in - entries.sorted { $0.executedAt > $1.executedAt }.prefix(limit).map(\.id) - }) - values.removeAll { !retained.contains($0.id) } - } - - public func insert(_ entry: CommandHistoryEntry, limit: Int) throws { - values.append(entry) - try trim(to: limit) - } - - public func update(_ entry: CommandHistoryEntry) throws { - guard let index = values.firstIndex(where: { $0.id == entry.id }) else { return } - values[index] = entry - } - - public func delete(_ id: CommandHistoryEntry.ID) throws { - values.removeAll { $0.id == id } - } - - public func clear(for hostID: Host.ID) throws { - values.removeAll { $0.hostID == hostID } - } -} diff --git a/Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift b/Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift deleted file mode 100644 index 15971df9..00000000 --- a/Frameworks/TermindApplication/Repositories/SFTPHistoryRepository.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import TermindDomain - -import struct TermindDomain.Host - -@MainActor -public protocol SFTPHistoryRepository: AnyObject { - func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] - func trim(to limit: Int) throws - func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws - func delete(_ id: SFTPHistoryEntry.ID) throws - func clear(for hostID: Host.ID) throws -} - -@MainActor -public final class InMemorySFTPHistoryRepository: SFTPHistoryRepository { - private var values: [SFTPHistoryEntry] - - public init(entries: [SFTPHistoryEntry] = []) { values = entries } - - public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] { - values.filter { $0.hostID == hostID }.sorted { $0.visitedAt > $1.visitedAt } - } - - public func trim(to limit: Int) throws { - let entriesByHost = Dictionary(grouping: values, by: \.hostID) - let retained = Set( - entriesByHost.values.flatMap { entries in - entries.sorted { $0.visitedAt > $1.visitedAt }.prefix(limit).map(\.id) - }) - values.removeAll { !retained.contains($0.id) } - } - - public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws { - values.removeAll { - $0.hostID == entry.hostID && $0.kind == entry.kind && $0.path == entry.path - } - values.append(entry) - try trim(to: limit) - } - - public func delete(_ id: SFTPHistoryEntry.ID) throws { - values.removeAll { $0.id == id } - } - - public func clear(for hostID: Host.ID) throws { - values.removeAll { $0.hostID == hostID } - } -} diff --git a/Frameworks/TermindApplication/Repositories/ShellHistoryLoading.swift b/Frameworks/TermindApplication/Repositories/ShellHistoryLoading.swift new file mode 100644 index 00000000..614d3593 --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/ShellHistoryLoading.swift @@ -0,0 +1,5 @@ +import TermindDomain + +public nonisolated protocol ShellHistoryLoading: Sendable { + func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] +} diff --git a/Frameworks/TermindDomain/Shell/CommandHistoryEntry.swift b/Frameworks/TermindDomain/Shell/CommandHistoryEntry.swift index 1b9197a9..156e134b 100644 --- a/Frameworks/TermindDomain/Shell/CommandHistoryEntry.swift +++ b/Frameworks/TermindDomain/Shell/CommandHistoryEntry.swift @@ -1,24 +1,21 @@ import Foundation -/// One command the remote shell confirmed it was about to execute. -/// -/// History is deliberately its own entity rather than a field on `Host`: a -/// busy terminal can append frequently without rewriting host configuration. +/// A command in one live terminal session. File records may have no execution timestamp. public nonisolated struct CommandHistoryEntry: Identifiable, Hashable, Sendable { public var id: UUID = UUID() - public var hostID: Host.ID + public var sessionID: UUID public var command: String - public var executedAt: Date = Date() + public var executedAt: Date? /// `nil` means the shell never reported a completion event, including rows /// written by versions that predate command lifecycle reporting. public var exitStatus: Int32? public var duration: TimeInterval? public init( - id: UUID = UUID(), hostID: Host.ID, command: String, executedAt: Date = Date(), exitStatus: Int32? = nil, + id: UUID = UUID(), sessionID: UUID, command: String, executedAt: Date? = nil, exitStatus: Int32? = nil, duration: TimeInterval? = nil ) { self.id = id - self.hostID = hostID + self.sessionID = sessionID self.command = command self.executedAt = executedAt self.exitStatus = exitStatus diff --git a/Frameworks/TermindDomain/Shell/ShellHistory.swift b/Frameworks/TermindDomain/Shell/ShellHistory.swift new file mode 100644 index 00000000..4dadba6e --- /dev/null +++ b/Frameworks/TermindDomain/Shell/ShellHistory.swift @@ -0,0 +1,193 @@ +import Foundation + +public nonisolated struct ShellHistorySource: Sendable, Equatable { + public enum Shell: String, Sendable { case bash, zsh, fish } + public var shell: Shell + /// nil asks for the shell's default; empty explicitly disables file history. + public var path: String? + + public init(shell: Shell, path: String? = nil) { + self.shell = shell + self.path = path + } +} + +public nonisolated struct ShellHistoryRecord: Sendable, Equatable { + public var command: String + public var executedAt: Date? + public var duration: TimeInterval? + + public init(command: String, executedAt: Date? = nil, duration: TimeInterval? = nil) { + self.command = command + self.executedAt = executedAt + self.duration = duration + } +} + +public nonisolated enum ShellHistoryFilter { + public static func isIntegrationCommand(_ command: String) -> Bool { + let text = command.trimmingCharacters(in: .whitespacesAndNewlines) + // Anchored signatures, not substring matches: echo/grep of these names is user work. + if text.hasPrefix("command true '__TRMD_"), text.contains("_PROBE__'") { return true } + if text.hasPrefix("if [[ ") || text.hasPrefix("if not set -q "), + text.contains("__termind_integration_token"), + text.contains("__termind_report_prompt"), text.contains("_DONE__") + { + return true + } + let name = text.prefix { $0.isLetter || $0.isNumber || $0 == "_" } + return [ + "__termind_report_command", "__termind_report_prompt", "__termind_report_cwd", + "__termind_report_finished", "__termind_prompt_begin", "__termind_prompt_end", + "__termind_compose_draft", "__termind_compose_clear", + ].contains(String(name)) + } +} + +/// Parses complete records in file order. It never invents timestamps or evaluates shell text. +public nonisolated enum ShellHistoryParser { + public static func parse( + _ text: String, shell: ShellHistorySource.Shell, truncatedStart: Bool = false + ) -> [ShellHistoryRecord] { + var lines = text.components(separatedBy: "\n") + // A file being appended may end halfway through a record. Only newline-terminated text is complete. + if !lines.isEmpty { lines.removeLast() } + if truncatedStart, !lines.isEmpty { lines.removeFirst() } + let records: [ShellHistoryRecord] + switch shell { + case .bash: records = bash(lines, truncatedStart: truncatedStart) + case .zsh: records = zsh(lines, truncatedStart: truncatedStart) + case .fish: records = fish(lines) + } + return records.filter { + !$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !ShellHistoryFilter.isIntegrationCommand($0.command) + } + } + + private static func timestamp(_ text: String) -> Date? { + guard !text.isEmpty, text.allSatisfy(\.isNumber), let seconds = Double(text), + seconds.isFinite, seconds > 0 + else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func bash(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] { + let hasMarkers = lines.contains { $0.hasPrefix("#") && timestamp(String($0.dropFirst())) != nil } + if !hasMarkers { return plain(lines) } + var records: [ShellHistoryRecord] = [] + var date: Date? + var command: [String] = [] + var accepts = !truncatedStart + func flush() { + if accepts, !command.isEmpty { + records.append(ShellHistoryRecord(command: command.joined(separator: "\n"), executedAt: date)) + } + command.removeAll() + } + for line in lines { + if line.hasPrefix("#"), let next = timestamp(String(line.dropFirst())) { + flush() + date = next + accepts = true + } else { + command.append(line) + } + } + flush() + return records + } + + private static func plain(_ lines: [String]) -> [ShellHistoryRecord] { + var result: [ShellHistoryRecord] = [] + var pending = "" + for line in lines { + pending += line + if trailingBackslashes(line).isMultiple(of: 2) { + result.append(ShellHistoryRecord(command: pending)) + pending = "" + } else { + pending += "\n" + } + } + return result + } + + private static func trailingBackslashes(_ line: String) -> Int { + line.reversed().prefix { $0 == "\\" }.count + } + + private static func zsh(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] { + var result: [ShellHistoryRecord] = [] + var pending: ShellHistoryRecord? + var continuing = false + let extended = lines.contains { header($0) != nil } + var accepts = !truncatedStart || !extended + for line in lines { + if !continuing { + if let parsed = header(line) { + pending = parsed + accepts = true + } else if accepts { + pending = ShellHistoryRecord(command: line) + } + } else { + pending?.command += "\n" + line + } + continuing = !trailingBackslashes(line).isMultiple(of: 2) + if continuing { + // Zsh uses backslash + physical newline to serialize a literal newline. + if pending != nil { pending!.command.removeLast() } + } else if let record = pending { + result.append(record) + pending = nil + } + } + return result + } + + private static func header(_ line: String) -> ShellHistoryRecord? { + guard line.hasPrefix(": "), let semicolon = line.firstIndex(of: ";") else { return nil } + let fields = line[line.index(line.startIndex, offsetBy: 2)..= 0 + else { return nil } + return ShellHistoryRecord( + command: String(line[line.index(after: semicolon)...]), executedAt: date, duration: duration) + } + + private static func fish(_ lines: [String]) -> [ShellHistoryRecord] { + var records: [ShellHistoryRecord] = [] + var current: ShellHistoryRecord? + for line in lines { + if line.hasPrefix("- cmd: ") { + if let current { records.append(current) } + current = ShellHistoryRecord(command: fishUnescape(String(line.dropFirst(7)))) + } else if line.hasPrefix(" when: ") { + current?.executedAt = timestamp(String(line.dropFirst(8))) + } + } + if let current { records.append(current) } + return records + } + + private static func fishUnescape(_ value: String) -> String { + var result = "" + var iterator = value.makeIterator() + while let character = iterator.next() { + if character == "\\", let next = iterator.next() { + if next == "n" { + result.append("\n") + } else if next == "\\" { + result.append("\\") + } else { + result.append("\\") + result.append(next) + } + } else { + result.append(character) + } + } + return result + } +} diff --git a/Frameworks/TermindDomain/Transfers/SFTPHistoryEntry.swift b/Frameworks/TermindDomain/Transfers/SFTPHistoryEntry.swift index 25807493..a68e79f7 100644 --- a/Frameworks/TermindDomain/Transfers/SFTPHistoryEntry.swift +++ b/Frameworks/TermindDomain/Transfers/SFTPHistoryEntry.swift @@ -10,7 +10,7 @@ public nonisolated struct SFTPHistoryEntry: Identifiable, Hashable, Sendable { } public var id: UUID = UUID() - public var hostID: Host.ID + public var sessionID: UUID public var kind: Kind public var path: String public var visitedAt: Date = Date() @@ -19,9 +19,9 @@ public nonisolated struct SFTPHistoryEntry: Identifiable, Hashable, Sendable { if path == "/" { return "/" } return (path as NSString).lastPathComponent } - public init(id: UUID = UUID(), hostID: Host.ID, kind: Kind, path: String, visitedAt: Date = Date()) { + public init(id: UUID = UUID(), sessionID: UUID, kind: Kind, path: String, visitedAt: Date = Date()) { self.id = id - self.hostID = hostID + self.sessionID = sessionID self.kind = kind self.path = path self.visitedAt = visitedAt diff --git a/Frameworks/TermindPersistence/CommandHistoryStore.swift b/Frameworks/TermindPersistence/CommandHistoryStore.swift deleted file mode 100644 index b93e60a9..00000000 --- a/Frameworks/TermindPersistence/CommandHistoryStore.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import SwiftData -import TermindApplication -import TermindDomain - -import struct TermindDomain.Host - -@MainActor -public final class SwiftDataCommandHistoryRepository: CommandHistoryRepository { - private let context: ModelContext - - public init(context: ModelContext) { self.context = context } - - public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.executedAt, order: .reverse)] - ) - ).map(\.value) - } - - public func trim(to limit: Int) throws { - let rows = try context.fetch( - FetchDescriptor( - sortBy: [SortDescriptor(\.executedAt, order: .reverse)] - )) - var counts: [Host.ID: Int] = [:] - let overflow = rows.filter { row in - let count = counts[row.hostID, default: 0] - counts[row.hostID] = count + 1 - return count >= limit - } - guard !overflow.isEmpty else { return } - try context.commit { - for row in overflow { context.delete(row) } - } - } - - public func insert(_ entry: CommandHistoryEntry, limit: Int) throws { - try context.commit { - let hostID = entry.hostID - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.executedAt, order: .reverse)] - ) - descriptor.fetchOffset = max(0, limit - 1) - for old in try context.fetch(descriptor) { context.delete(old) } - context.insert(PersistentCommandHistoryEntry(entry)) - } - } - - public func update(_ entry: CommandHistoryEntry) throws { - let id = entry.id - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let existing = try context.fetch(descriptor).first else { return } - try context.commit { existing.apply(entry) } - } - - public func delete(_ id: CommandHistoryEntry.ID) throws { - try context.commit { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - if let entry = try context.fetch(descriptor).first { context.delete(entry) } - } - } - - public func clear(for hostID: Host.ID) throws { - try context.commit { - try context.delete( - model: PersistentCommandHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - } - } -} diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 775055bd..4bf9258a 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -923,6 +923,16 @@ public enum TermindSchemaV48: VersionedSchema { } } +/// Removes local command and SFTP history; history now lives with each active session. +public enum TermindSchemaV49: VersionedSchema { + public static let versionIdentifier = Schema.Version(49, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV48.models.filter { + $0 != PersistentCommandHistoryEntry.self && $0 != PersistentSFTPHistoryEntry.self + } + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -938,7 +948,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self, TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self, TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self, - TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, + TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self, ] } @@ -1046,6 +1056,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV45.self, toVersion: TermindSchemaV46.self), MigrationStage.lightweight(fromVersion: TermindSchemaV46.self, toVersion: TermindSchemaV47.self), MigrationStage.lightweight(fromVersion: TermindSchemaV47.self, toVersion: TermindSchemaV48.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV48.self, toVersion: TermindSchemaV49.self), ] } } diff --git a/Frameworks/TermindPersistence/SwiftDataCommandHistoryRepository.swift b/Frameworks/TermindPersistence/SwiftDataCommandHistoryRepository.swift index b099921a..4b020d66 100644 --- a/Frameworks/TermindPersistence/SwiftDataCommandHistoryRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataCommandHistoryRepository.swift @@ -12,11 +12,10 @@ public enum TermindSchemaV9Models { public var command = "" public var executedAt = Date(timeIntervalSince1970: 0) - public init(_ value: CommandHistoryEntry) { - id = value.id - hostID = value.hostID - command = value.command - executedAt = value.executedAt + public init(hostID: UUID, command: String, executedAt: Date) { + self.hostID = hostID + self.command = command + self.executedAt = executedAt } } } @@ -30,23 +29,9 @@ public final class PersistentCommandHistoryEntry { public var exitStatus: Int32? public var duration: TimeInterval? - public init(_ value: CommandHistoryEntry) { - apply(value) - } - - public func apply(_ value: CommandHistoryEntry) { - id = value.id - hostID = value.hostID - command = value.command - executedAt = value.executedAt - exitStatus = value.exitStatus - duration = value.duration - } - - public var value: CommandHistoryEntry { - CommandHistoryEntry( - id: id, hostID: hostID, command: command, executedAt: executedAt, - exitStatus: exitStatus, duration: duration - ) + public init(hostID: UUID, command: String, executedAt: Date) { + self.hostID = hostID + self.command = command + self.executedAt = executedAt } } diff --git a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift index 82313bff..86a3d1f5 100644 --- a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift @@ -116,14 +116,6 @@ public final class SwiftDataHostRepository: HostRepository { snippet.targetHostIDs.removeAll { $0 == hostID } } for rule in try forwardingRules(for: hostID) { context.delete(rule) } - try context.delete( - model: PersistentCommandHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - try context.delete( - model: PersistentSFTPHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) try HistorySyncJournal(context: context).bootstrapIfNeeded() try context.delete( model: PersistentConnectionHistoryEntry.self, diff --git a/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift b/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift index 393a66c5..7faf6861 100644 --- a/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataSFTPHistoryRepository.swift @@ -1,110 +1,18 @@ import Foundation import SwiftData -import TermindApplication -import TermindDomain - -import struct TermindDomain.Host @Model public final class PersistentSFTPHistoryEntry { @Attribute(.unique) public var id: UUID = UUID() public var hostID: UUID = UUID() - public var kind = SFTPHistoryEntry.Kind.directory.rawValue + public var kind = "directory" public var path = "/" public var visitedAt = Date(timeIntervalSince1970: 0) - public init(_ value: SFTPHistoryEntry) { - id = value.id - hostID = value.hostID - kind = value.kind.rawValue - path = value.path - visitedAt = value.visitedAt - } - - public var value: SFTPHistoryEntry { - SFTPHistoryEntry( - id: id, hostID: hostID, - kind: SFTPHistoryEntry.Kind(rawValue: kind) ?? .directory, - path: path, visitedAt: visitedAt - ) - } -} - -@MainActor -public final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository { - private let context: ModelContext - - public init(context: ModelContext) { self.context = context } - - public func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] - ) - ).map(\.value) - } - - public func trim(to limit: Int) throws { - let rows = try context.fetch( - FetchDescriptor( - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] - )) - var counts: [Host.ID: Int] = [:] - let overflow = rows.filter { row in - let count = counts[row.hostID, default: 0] - counts[row.hostID] = count + 1 - return count >= limit - } - guard !overflow.isEmpty else { return } - try context.commit { - for row in overflow { context.delete(row) } - } - } - - public func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws { - let hostID = entry.hostID - try context.commit { - let rows = try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] - )) - if let row = rows.first(where: { - $0.kind == entry.kind.rawValue && $0.path == entry.path - }) { - row.visitedAt = entry.visitedAt - } else { - context.insert(PersistentSFTPHistoryEntry(entry)) - } - } - - try context.commit { - var overflow = FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] - ) - overflow.fetchOffset = limit - for row in try context.fetch(overflow) { context.delete(row) } - } - } - - public func delete(_ id: SFTPHistoryEntry.ID) throws { - try context.commit { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - if let row = try context.fetch(descriptor).first { context.delete(row) } - } - } - - public func clear(for hostID: Host.ID) throws { - try context.commit { - try context.delete( - model: PersistentSFTPHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - } + public init(hostID: UUID, kind: String, path: String, visitedAt: Date) { + self.hostID = hostID + self.kind = kind + self.path = path + self.visitedAt = visitedAt } } diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index b1e1723c..c4ff0b8e 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -20,7 +20,7 @@ public enum TermindPersistence { #endif // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV48.self) + public static let schema = Schema(versionedSchema: TermindSchemaV49.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPresentation/CommandHistoryPresentation.swift b/Frameworks/TermindPresentation/CommandHistoryPresentation.swift index 3017647e..4acc3ecf 100644 --- a/Frameworks/TermindPresentation/CommandHistoryPresentation.swift +++ b/Frameworks/TermindPresentation/CommandHistoryPresentation.swift @@ -10,11 +10,10 @@ public nonisolated enum CommandHistoryPresentation { } public static func subtitle(for entry: CommandHistoryEntry, locale: Locale) -> String { - var parts = [ - entry.executedAt.formatted( - .relative(presentation: .numeric).locale(locale) - ) - ] + var parts: [String] = [] + if let date = entry.executedAt { + parts.append(date.formatted(.relative(presentation: .numeric).locale(locale))) + } if let duration = entry.duration, let formatted = formattedDuration(duration) { parts.append(formatted) } diff --git a/Frameworks/TermindPresentation/Strings/SFTP.swift b/Frameworks/TermindPresentation/Strings/SFTP.swift index 233b60a6..e8f06cdd 100644 --- a/Frameworks/TermindPresentation/Strings/SFTP.swift +++ b/Frameworks/TermindPresentation/Strings/SFTP.swift @@ -179,10 +179,6 @@ extension AppStrings { "Choose an edited copy with the same file format as the original." } - public static var clearFileHistoryForThisHostConfirmation: LocalizedStringResource { - "Clear File History for this Host?" - } - public static var clearFinished: LocalizedStringResource { "Clear Finished" } diff --git a/Frameworks/TermindPresentation/Strings/Terminal.swift b/Frameworks/TermindPresentation/Strings/Terminal.swift index 1795050e..d56e14da 100644 --- a/Frameworks/TermindPresentation/Strings/Terminal.swift +++ b/Frameworks/TermindPresentation/Strings/Terminal.swift @@ -12,10 +12,6 @@ extension AppStrings { "Charset Error" } - public static var clearHistoryConfirmation: LocalizedStringResource { - "Clear History?" - } - public static var connectionLog: LocalizedStringResource { "Connection Log" } @@ -189,19 +185,12 @@ extension AppStrings { "Write serial port" } - public static var clearCommandHistoryWarning: LocalizedStringResource { - "This permanently removes all saved commands for the current Host." - } - public static var commandHistoryRequirement: LocalizedStringResource { - "This session is not recording history. Enable Shell Integration and use bash, zsh, or fish." + "Enable Shell Integration to add commands to this session’s history in real time." } - public static var clearCommandHistoryForThisHostConfirmation: LocalizedStringResource { - "Clear command history for this Host?" - } public static var commandsConfirmedByTheRemoteShellAppearHere: LocalizedStringResource { - "Commands confirmed by the remote shell appear here." + "Shell history and commands from this session appear here." } public static var copyLogs: LocalizedStringResource { "Copy Logs" diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift new file mode 100644 index 00000000..7c9acf8e --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellHistoryLoader.swift @@ -0,0 +1,104 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// A bounded, read-only exec operation on the terminal's authenticated connection. +public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading { + public static let maximumFileBytes = 2 * 1024 * 1024 + private let execute: @Sendable (String, Int) async throws -> SSHExecResult + private let resolveSource: @Sendable () async throws -> ShellHistorySource? + private let charset: RemoteCharset + + public init(channel: SSHPTYChannel, charset: RemoteCharset) { + self.init( + charset: charset, + resolveSource: { try await channel.historySource() }, + execute: { try await channel.executeHistoryRead($0, outputLimit: $1) }) + } + + init( + charset: RemoteCharset, + resolveSource: @escaping @Sendable () async throws -> ShellHistorySource?, + execute: @escaping @Sendable (String, Int) async throws -> SSHExecResult + ) { + self.charset = charset + self.resolveSource = resolveSource + self.execute = execute + } + + public func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { + try await withThrowingTaskGroup(of: [ShellHistoryRecord].self) { group in + group.addTask { try await read(source: source) } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TermindSSHError.library("History read timed out.") + } + defer { group.cancelAll() } + return try await group.next() ?? [] + } + } + + private func read(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { + let resolved: ShellHistorySource? + if let source { resolved = source } else { resolved = try await resolveSource() } + let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let result = try await execute(Self.command(source: resolved, token: token), 3 * 1024 * 1024) + guard result.exitCode == 0, !result.stdoutTruncated else { + throw TermindSSHError.library("History file could not be read.") + } + guard let start = result.stdout.range(of: "__TRMD_HISTORY_\(token)__"), + let lineEnd = result.stdout[start.upperBound...].firstIndex(of: "\n"), + let end = result.stdout.range(of: "__TRMD_HISTORY_END_\(token)__", range: lineEnd..= 0 else { + throw TermindSSHError.library("History response was invalid.") + } + if size == 0 { return [] } + guard let shell = ShellHistorySource.Shell(rawValue: String(fields[0])), + var bytes = Data( + base64Encoded: String(result.stdout[lineEnd.. Self.maximumFileBytes) + .suffix(HistoryLimits.retainedEntries)) + } + + static func command(source: ShellHistorySource?, token: String) -> String { + let shell = source.map { quote($0.shell.rawValue) } ?? "\"${SHELL##*/}\"" + let path = + source?.path.map { "file=\(quote($0));" } + ?? #"case "$kind" in bash) file=${HISTFILE-$HOME/.bash_history} ;; zsh) file=${HISTFILE-${ZDOTDIR-$HOME}/.zsh_history} ;; fish) name=${fish_history-fish}; if [ -n "$name" ]; then file=${XDG_DATA_HOME-$HOME/.local/share}/fish/${name}_history; else file=; fi ;; *) file= ;; esac;"# + // The file is data, never shell source. Base64 preserves zsh metafied and non-UTF-8 bytes + // across the ordinary exec charset decoder. Framing excludes login/profile chatter. + return """ + kind=\(shell); \(path) + if [ -z "$file" ] || [ ! -e "$file" ]; then + printf '__TRMD_HISTORY_\(token)__%s;0\\n__TRMD_HISTORY_END_\(token)__\\n' "$kind"; exit 0 + fi + [ -f "$file" ] && [ -r "$file" ] || exit 1 + exec 3< "$file" || exit 1 + size=$(wc -c < "$file") || exit 1 + size=$(printf '%s' "$size" | tr -d '[:space:]') + printf '__TRMD_HISTORY_\(token)__%s;%s\\n' "$kind" "$size" + tail -c \(maximumFileBytes) <&3 | base64 || exit 1 + printf '\\n__TRMD_HISTORY_END_\(token)__\\n' + """ + } + + private static func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func unmetafy(_ input: Data) -> Data { + var output = Data() + var iterator = input.makeIterator() + while let byte = iterator.next() { + if byte == 0x83, let next = iterator.next() { output.append(next ^ 0x20) } else { output.append(byte) } + } + return output + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift index c958948a..25ebb383 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift @@ -12,87 +12,6 @@ import TermindDomain /// commands Session-level concurrency without violating libssh2's single-owner /// requirement or letting a quiet/blocked command pin its peers. nonisolated final class SSHConnectionWorker: @unchecked Sendable { - private final class StableCString: @unchecked Sendable { - let pointer: UnsafeMutablePointer - let count: Int - - init(_ data: Data) { - let bytes = Array(data) - count = bytes.count - pointer = .allocate(capacity: count + 1) - for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) } - pointer[count] = 0 - } - - deinit { pointer.deallocate() } - } - - private var agentForwardingRequested = false - private var agentRequestOwner: UUID? - - private final class SSHExecChannel: @unchecked Sendable { - enum Phase { case opening, forwarding, starting, running, closing, freeing } - - let id = UUID() - let command: StableCString - let cancellation: TransferCancellationToken - let deadline: ContinuousClock.Instant - let outputLimit: Int - let onOutput: SSHExecOutputHandler? - let continuation: CheckedContinuation - let channelLease: SSHChannelLease - let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true) - var phase = Phase.opening - var execChannel: OpaquePointer? - var sentEOF = false - var stdout = Data() - var stderr = Data() - var stdoutRemoteBytes = 0 - var stderrRemoteBytes = 0 - let stdoutDecoder: RemoteCharsetStreamConverter - let stderrDecoder: RemoteCharsetStreamConverter - let charset: RemoteCharset - var stdoutTruncated = false - var stderrTruncated = false - var terminalError: (any Error)? - var exitCode: Int32 = 0 - var signal: String? - var started = false - var completed = false - - init( - command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset, - timeout: TimeInterval, - outputLimit: Int, cancellation: TransferCancellationToken, - onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease, - continuation: CheckedContinuation - ) throws { - let effective: String - if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { - effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))" - } else { - effective = "/bin/sh -lc \(Self.shellQuote(command))" - } - self.command = StableCString( - try RemoteCharsetStreamConverter.encode(effective, charset: charset)) - self.charset = charset - stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) - stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) - self.cancellation = cancellation - deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60)))) - self.outputLimit = outputLimit - self.onOutput = onOutput - self.channelLease = channelLease - self.continuation = continuation - try stdin?.append(to: input, charset: charset) - sentEOF = input.isEmpty - } - - private static func shellQuote(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - } - private let queue: DispatchQueue private let queueKey = DispatchSpecificKey() private let logger: Logger @@ -104,16 +23,12 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { private var timer: (any DispatchSourceTimer)? private var hostID: UUID? private var charset: RemoteCharset = .utf8 - private var commands: [UUID: SSHExecChannel] = [:] - private var pendingOpens = SSHReadyQueue() - private var opening: SSHExecChannel? - private var ready = SSHReadyQueue() private var scheduled = false private var generation = 0 private var controlDirections: Int32 = 0 private var keepaliveDue = true private var nextKeepalive = DispatchTime.now() - private var scratch = [CChar](repeating: 0, count: 16 * 1024) + private var execChannelOwner: SSHMultiplexedExecChannelOwner! private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner! private var connectionFailure: (@Sendable (String) -> Void)? private var didReportConnectionFailure = false @@ -126,6 +41,21 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { self.logger = logger self.maxSessionsPolicy = maxSessionsPolicy queue.setSpecific(key: queueKey, value: ()) + execChannelOwner = SSHMultiplexedExecChannelOwner( + queue: queue, logger: logger, + context: { [weak self] in + guard let self, let connection = self.connection, let buffer = self.buffer, + let channelBudget = self.channelBudget + else { return nil } + return .init( + connection: connection, buffer: buffer, channelBudget: channelBudget, + charset: self.charset, hostID: self.hostID) + }, + schedulePump: { [weak self] in self?.schedulePump() }, + connectionFailed: { [weak self] error in + guard let self, let connection = self.connection else { return } + self.failConnection(connection.libssh2Session, error: error) + }) sftpChannelOwner = SSHMultiplexedSFTPChannelOwner( queue: queue, context: { [weak self] in @@ -215,57 +145,9 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { - guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw TermindSSHError.library("The command is empty.") - } - let operationID = UUID().uuidString.lowercased() - var metadata: Logger.Metadata = [ - "operation_id": "\(operationID)", "protocol": "ssh", "stage": "executing", - ] - if let hostID = try? await queue.perform({ self.hostID }) { - metadata["host_id"] = RemoteLogging.hostID(hostID) - } - logger.debug("ssh.exec.started", metadata: metadata) - do { - guard let channelBudget = try await queue.perform({ self.channelBudget }) else { - throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected) - } - let channelLease = try await channelBudget.acquire(cancellation: cancellation) - let result = try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation) in - queue.async { [self] in - guard connection != nil, self.channelBudget === channelBudget else { - channelLease.release() - continuation.resume( - throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected)) - return - } - let context: SSHExecChannel - do { - context = try SSHExecChannel( - command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset, - timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, - onOutput: onOutput, channelLease: channelLease, continuation: continuation) - } catch { - channelLease.release() - continuation.resume(throwing: error) - return - } - commands[context.id] = context - pendingOpens.insert(context.id) - schedulePump() - } - } - metadata["outcome"] = "success" - metadata["stage"] = "completed" - logger.debug("ssh.exec.completed", metadata: metadata) - return result - } catch { - metadata["error_code"] = RemoteLogging.errorCode(error) - metadata["outcome"] = "failure" - logger.error("ssh.exec.failed", metadata: metadata) - throw error - } + try await execChannelOwner.execute( + command, workingDirectory: workingDirectory, stdin: stdin, + timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput) } func openSFTPChannel() async throws -> any SFTPChannelProtocol { @@ -309,7 +191,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } private func wakeAll() { - for id in commands.keys { ready.insert(id) } + execChannelOwner.wakeAllOnQueue() schedulePump() } @@ -343,18 +225,10 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { deadline: min( deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2), permitsIO: { !buffer.isBackpressured }) ?? false - if advanceOpen(session) { progressed = true } - while !buffer.isBackpressured, - DispatchTime.now().uptimeNanoseconds < deadline, - let id = ready.pop() - { - guard let command = commands[id] else { continue } - if advance(command, session: session, deadline: deadline) { progressed = true } - } + if execChannelOwner.advanceOnQueue(deadline: deadline) { progressed = true } if buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) == true { progressed = true } - if advanceOpen(session) { progressed = true } if keepaliveDue { var next: Int32 = 0 let result = libssh2_keepalive_send(session, &next) @@ -379,323 +253,17 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { failConnection(session) return } - let directions = commands.values.reduce(controlDirections) { partial, command in - guard command.execChannel != nil else { return partial } - return partial | libssh2_session_block_directions(session) - } + let directions = controlDirections | execChannelOwner.controlDirections socketEvents?.update( - read: !commands.isEmpty && !buffer.isBackpressured, + read: !execChannelOwner.isEmptyOnQueue && !buffer.isBackpressured, write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) if progressed || buffer.exhaustedReadBudget { wakeAll() } } - private func advanceOpen(_ session: OpaquePointer) -> Bool { - if opening == nil { - while let id = pendingOpens.pop() { - if let command = commands[id], command.phase == .opening { - opening = command - break - } - } - } - guard let command = opening else { return false } - if cancellationError(for: command) != nil { - opening = nil - finish(command, throwing: cancellationError(for: command)!) - return true - } - guard - let execChannel = libssh2_channel_open_ex( - session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0) - else { - let code = libssh2_session_last_errno(session) - if code == LIBSSH2_ERROR_EAGAIN { - blocked(session) - return false - } - opening = nil - let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to open SSH exec Channel") - if isConnectionError(code) { - failConnection(session, error: error) - } else { - finish(command, throwing: SSHCommandExecutionError.channelUnavailable(error)) - } - return true - } - opening = nil - command.execChannel = execChannel - command.phase = buffer?.agentChannels == nil ? .starting : .forwarding - ready.insert(command.id) - return true - } - - private func advance(_ command: SSHExecChannel, session: OpaquePointer, deadline: UInt64) -> Bool { - if let cancellation = cancellationError(for: command), command.terminalError == nil { - if agentRequestOwner == command.id { - // A cancelled, unanswered connection-wide request cannot safely - // be repeated on another channel: the peer may already have accepted it. - failConnection(session, error: cancellation) - return false - } - command.terminalError = cancellation - if command.execChannel == nil { - finish(command, throwing: cancellation) - return true - } - command.phase = .closing - } - guard let execChannel = command.execChannel else { return false } - switch command.phase { - case .opening: - return false - case .forwarding: - if agentForwardingRequested { - command.phase = .starting - ready.insert(command.id) - return true - } - guard agentRequestOwner == nil || agentRequestOwner == command.id else { return false } - agentRequestOwner = command.id - let result = libssh2_channel_request_auth_agent(execChannel) - if result == LIBSSH2_ERROR_EAGAIN { - blocked(session) - return false - } - agentRequestOwner = nil - if result != 0 { - command.terminalError = SSHCommandExecutionError.channelUnavailable( - SSHConnectionOpener.connectionError(session, prefix: "Agent Forwarding request failed")) - command.phase = .closing - } else { - agentForwardingRequested = true - command.phase = .starting - } - ready.insert(command.id) - return true - case .starting: - let result = libssh2_channel_process_startup( - execChannel, "exec", 4, command.command.pointer, UInt32(command.command.count)) - if result == LIBSSH2_ERROR_EAGAIN { - blocked(session) - return false - } - guard result == 0 else { - let code = libssh2_session_last_errno(session) - let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to start remote command") - command.terminalError = SSHCommandExecutionError.channelUnavailable(error) - command.phase = .closing - if isConnectionError(code) { failConnection(session, error: error) } - ready.insert(command.id) - return true - } - command.started = true - command.phase = .running - ready.insert(command.id) - return true - case .running: - return advanceRunning(command, execChannel: execChannel, session: session, deadline: deadline) - case .closing: - let result = libssh2_channel_close(execChannel) - if result == LIBSSH2_ERROR_EAGAIN { - blocked(session) - return false - } - if command.terminalError == nil { - command.exitCode = libssh2_channel_get_exit_status(execChannel) - command.signal = exitSignal(execChannel: execChannel, session: session) - } - command.phase = .freeing - ready.insert(command.id) - return true - case .freeing: - let result = libssh2_channel_free(execChannel) - if result == LIBSSH2_ERROR_EAGAIN { - blocked(session) - return false - } - command.execChannel = nil - if let error = command.terminalError { - finish(command, throwing: error) - } else { - let stdout = String(decoding: command.stdout, as: UTF8.self) - let stderr = String(decoding: command.stderr, as: UTF8.self) - finish( - command, - returning: SSHExecResult( - stdout: stdout, stderr: stderr, - exitCode: command.exitCode, signal: command.signal, - stdoutTruncated: command.stdoutTruncated, - stderrTruncated: command.stderrTruncated)) - } - return true - } - } - - private func advanceRunning( - _ command: SSHExecChannel, execChannel: OpaquePointer, session: OpaquePointer, deadline: UInt64 - ) -> Bool { - var progressed = false - var remaining = SSHForwardingTuning.current.quantum - if !command.input.isEmpty { - let written = command.input.withFirstBytes { - libssh2_channel_write_ex( - execChannel, 0, $0.baseAddress!.assumingMemoryBound(to: CChar.self), $0.count) - } - if written > 0 { - command.input.consume(written) - remaining -= min(remaining, written) - progressed = true - } else if written == LIBSSH2_ERROR_EAGAIN { - blocked(session) - } else { - return fail(command, session: session, prefix: "Unable to write command input") - } - } - if command.input.isEmpty, !command.sentEOF { - let result = libssh2_channel_send_eof(execChannel) - if result == 0 { - command.sentEOF = true - progressed = true - } else if result == LIBSSH2_ERROR_EAGAIN { - blocked(session) - } else { - return fail(command, session: session, prefix: "Unable to finish command input") - } - } - for stream in [Int32(0), Int32(1)] { - while remaining > 0 && DispatchTime.now().uptimeNanoseconds < deadline { - let capacity = min(scratch.count, remaining) - let count = libssh2_channel_read_ex(execChannel, stream, &scratch, capacity) - if count > 0 { - progressed = true - remaining -= count - let chunk = Data(bytes: scratch, count: count) - do { - try appendDecoded(chunk, stream: stream, to: command) - } catch { - command.terminalError = error - command.phase = .closing - ready.insert(command.id) - return true - } - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - if count == LIBSSH2_ERROR_EAGAIN { blocked(session) } - break - } else { - return fail(command, session: session, prefix: "Unable to read command output") - } - } - } - if libssh2_channel_eof(execChannel) == 1 { - do { - try finishDecodedOutput(command) - } catch { - command.terminalError = error - } - command.phase = .closing - ready.insert(command.id) - return true - } - if !command.input.isEmpty || !command.sentEOF || libssh2_poll_channel_read(execChannel, 0) == 1 { - ready.insert(command.id) - } - return progressed - } - - private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws { - let isError = stream != 0 - let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes - let available = max(0, command.outputLimit - consumed) - let accepted = Data(chunk.prefix(available)) - if isError { - command.stderrRemoteBytes += accepted.count - if accepted.count < chunk.count { command.stderrTruncated = true } - } else { - command.stdoutRemoteBytes += accepted.count - if accepted.count < chunk.count { command.stdoutTruncated = true } - } - guard !accepted.isEmpty else { return } - let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data - guard !decoded.isEmpty else { return } - command.onOutput?(decoded, isError) - if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) } - } - - private func finishDecodedOutput(_ command: SSHExecChannel) throws { - if !command.stdoutTruncated { - let tail = try command.stdoutDecoder.finish().data - if !tail.isEmpty { - command.onOutput?(tail, false) - command.stdout.append(tail) - } - } - if !command.stderrTruncated { - let tail = try command.stderrDecoder.finish().data - if !tail.isEmpty { - command.onOutput?(tail, true) - command.stderr.append(tail) - } - } - } - - private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool { - let code = libssh2_session_last_errno(session) - let error = SSHConnectionOpener.connectionError(session, prefix: prefix) - if isConnectionError(code) { - failConnection(session, error: error) - } else { - command.terminalError = error - command.phase = .closing - ready.insert(command.id) - } - return true - } - - private func cancellationError(for command: SSHExecChannel) -> (any Error)? { - if command.cancellation.isCancelled { return CancellationError() } - if ContinuousClock.now >= command.deadline { - return TermindSSHError.library("The remote command timed out.") - } - return nil - } - - private func exitSignal(execChannel: OpaquePointer, session: OpaquePointer) -> String? { - var pointer: UnsafeMutablePointer? - var length = 0 - let result = libssh2_channel_get_exit_signal(execChannel, &pointer, &length, nil, nil, nil, nil) - guard result == 0, let pointer else { return nil } - defer { libssh2_free(session, pointer) } - return String( - decoding: UnsafeBufferPointer(start: pointer, count: length).map(UInt8.init(bitPattern:)), - as: UTF8.self) - } - - private func finish(_ command: SSHExecChannel, returning result: SSHExecResult) { - guard !command.completed else { return } - command.completed = true - commands[command.id] = nil - command.continuation.resume(returning: result) - } - - private func finish(_ command: SSHExecChannel, throwing error: any Error) { - guard !command.completed else { return } - command.completed = true - commands[command.id] = nil - command.continuation.resume(throwing: error) - } - private func blocked(_ session: OpaquePointer) { controlDirections |= libssh2_session_block_directions(session) } - private func isConnectionError(_ code: Int32) -> Bool { - [ - LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, - LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT, - LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_BAD_SOCKET, - ].contains(code) - } - private func failConnection(_ session: OpaquePointer, error: (any Error)? = nil) { let failure = error ?? SSHConnectionOpener.connectionError(session, prefix: "The SSH connection closed") if !didReportConnectionFailure { @@ -707,17 +275,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { private func cleanup(error: any Error) { buffer?.agentChannels?.shutdown() - agentForwardingRequested = false - agentRequestOwner = nil generation += 1 scheduled = false timer?.cancel() timer = nil - for command in Array(commands.values) { finish(command, throwing: error) } - commands.removeAll() - opening = nil - pendingOpens = SSHReadyQueue() - ready = SSHReadyQueue() + execChannelOwner.closeAllOnQueue(error: error) sftpChannelOwner.closeAllOnQueue() channelBudget?.close() channelBudget = nil diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift new file mode 100644 index 00000000..641f79df --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedExecChannelOwner.swift @@ -0,0 +1,550 @@ +import CSSH2 +import Darwin +import Foundation +import Logging +import TermindApplication +import TermindDomain + +/// Shared exec state machines. All libssh2 calls run on the enclosing connection's queue. +nonisolated final class SSHMultiplexedExecChannelOwner: @unchecked Sendable { + struct Context: @unchecked Sendable { + var connection: SSHAuthenticatedConnection + var buffer: SSHConnectionBuffer + var channelBudget: SSHChannelBudget + var charset: RemoteCharset + var hostID: UUID? + } + private let queue: DispatchQueue + private let logger: Logger + private let context: @Sendable () -> Context? + private let schedulePump: @Sendable () -> Void + private let connectionFailed: @Sendable (any Error) -> Void + private let didCloseLastChannel: @Sendable () -> Void + private let requestsAgentForwarding: Bool + private var buffer: SSHConnectionBuffer? { context()?.buffer } + private var charset: RemoteCharset { context()?.charset ?? .utf8 } + private var isClosingAll = false + private var commands: [UUID: SSHExecChannel] = [:] + private var pendingOpens = SSHReadyQueue() + private var opening: SSHExecChannel? + private var ready = SSHReadyQueue() + private var scratch = [CChar](repeating: 0, count: 16 * 1024) + private(set) var controlDirections: Int32 = 0 + var isEmptyOnQueue: Bool { commands.isEmpty } + + init( + queue: DispatchQueue, logger: Logger, requestsAgentForwarding: Bool = true, + context: @escaping @Sendable () -> Context?, + schedulePump: @escaping @Sendable () -> Void, + connectionFailed: @escaping @Sendable (any Error) -> Void, + didCloseLastChannel: @escaping @Sendable () -> Void = {} + ) { + self.queue = queue + self.logger = logger + self.requestsAgentForwarding = requestsAgentForwarding + self.context = context + self.schedulePump = schedulePump + self.connectionFailed = connectionFailed + self.didCloseLastChannel = didCloseLastChannel + } + + func wakeAllOnQueue() { + for id in commands.keys { ready.insert(id) } + } + + func advanceOnQueue(deadline: UInt64) -> Bool { + guard let context = context() else { return false } + controlDirections = 0 + let session = context.connection.libssh2Session + var progressed = advanceOpen(session) + while !context.buffer.isBackpressured, DispatchTime.now().uptimeNanoseconds < deadline, + let id = ready.pop() + { + guard let command = commands[id] else { continue } + if advance(command, session: session, deadline: deadline) { progressed = true } + } + if self.context() != nil, advanceOpen(session) { progressed = true } + return progressed + } + + func cancelAllOnQueue() { + for command in commands.values { command.cancellation.cancel() } + wakeAllOnQueue() + } + + func closeAllOnQueue(error: any Error) { + isClosingAll = true + defer { isClosingAll = false } + for command in Array(commands.values) { finish(command, throwing: error) } + commands.removeAll() + opening = nil + pendingOpens = SSHReadyQueue() + ready = SSHReadyQueue() + agentForwardingRequested = false + agentRequestOwner = nil + } + + private func failConnection(_ session: OpaquePointer, error: (any Error)? = nil) { + connectionFailed(error ?? SSHConnectionOpener.connectionError(session, prefix: "The SSH connection closed")) + } + + private final class StableCString: @unchecked Sendable { + let pointer: UnsafeMutablePointer + let count: Int + + init(_ data: Data) { + let bytes = Array(data) + count = bytes.count + pointer = .allocate(capacity: count + 1) + for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) } + pointer[count] = 0 + } + + deinit { pointer.deallocate() } + } + + private var agentForwardingRequested = false + private var agentRequestOwner: UUID? + + private final class SSHExecChannel: @unchecked Sendable { + enum Phase { case opening, forwarding, starting, running, closing, freeing } + + let id = UUID() + let command: StableCString + let cancellation: TransferCancellationToken + let deadline: ContinuousClock.Instant + let outputLimit: Int + let onOutput: SSHExecOutputHandler? + let continuation: CheckedContinuation + let channelLease: SSHChannelLease + let input = SSHByteQueue(chunkSize: 32_700, zeroizesConsumedBytes: true) + var phase = Phase.opening + var execChannel: OpaquePointer? + var sentEOF = false + var stdout = Data() + var stderr = Data() + var stdoutRemoteBytes = 0 + var stderrRemoteBytes = 0 + let stdoutDecoder: RemoteCharsetStreamConverter + let stderrDecoder: RemoteCharsetStreamConverter + let charset: RemoteCharset + var stdoutTruncated = false + var stderrTruncated = false + var terminalError: (any Error)? + var exitCode: Int32 = 0 + var signal: String? + var started = false + var completed = false + + init( + command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset, + usesLoginShell: Bool, + timeout: TimeInterval, + outputLimit: Int, cancellation: TransferCancellationToken, + onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease, + continuation: CheckedContinuation + ) throws { + let shellFlags = usesLoginShell ? "-lc" : "-c" + let effective: String + if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { + effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh \(shellFlags) \(Self.shellQuote(command))" + } else { + effective = "/bin/sh \(shellFlags) \(Self.shellQuote(command))" + } + self.command = StableCString( + try RemoteCharsetStreamConverter.encode(effective, charset: charset)) + self.charset = charset + stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) + stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) + self.cancellation = cancellation + deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60)))) + self.outputLimit = outputLimit + self.onOutput = onOutput + self.channelLease = channelLease + self.continuation = continuation + try stdin?.append(to: input, charset: charset) + sentEOF = input.isEmpty + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + } + + func execute( + _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, + outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?, + usesLoginShell: Bool = true + ) async throws -> SSHExecResult { + guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TermindSSHError.library("The command is empty.") + } + let operationID = UUID().uuidString.lowercased() + var metadata: Logger.Metadata = [ + "operation_id": "\(operationID)", "protocol": "ssh", "stage": "executing", + ] + if let hostID = try? await queue.perform({ self.context()?.hostID }) { + metadata["host_id"] = RemoteLogging.hostID(hostID) + } + logger.debug("ssh.exec.started", metadata: metadata) + do { + guard let channelBudget = try await queue.perform({ self.context()?.channelBudget }) else { + throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected) + } + let channelLease = try await channelBudget.acquire(cancellation: cancellation) + let result = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + queue.async { [self] in + guard self.context() != nil, self.context()?.channelBudget === channelBudget else { + channelLease.release() + continuation.resume( + throwing: SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected)) + return + } + let context: SSHExecChannel + do { + context = try SSHExecChannel( + command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset, + usesLoginShell: usesLoginShell, + timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, + onOutput: onOutput, channelLease: channelLease, continuation: continuation) + } catch { + channelLease.release() + continuation.resume(throwing: error) + return + } + commands[context.id] = context + pendingOpens.insert(context.id) + schedulePump() + } + } + metadata["outcome"] = "success" + metadata["stage"] = "completed" + logger.debug("ssh.exec.completed", metadata: metadata) + return result + } catch { + metadata["error_code"] = RemoteLogging.errorCode(error) + metadata["outcome"] = "failure" + logger.error("ssh.exec.failed", metadata: metadata) + throw error + } + } + + private func advanceOpen(_ session: OpaquePointer) -> Bool { + if opening == nil { + while let id = pendingOpens.pop() { + if let command = commands[id], command.phase == .opening { + opening = command + break + } + } + } + guard let command = opening else { return false } + if cancellationError(for: command) != nil { + opening = nil + finish(command, throwing: cancellationError(for: command)!) + return true + } + guard + let execChannel = libssh2_channel_open_ex( + session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0) + else { + let code = libssh2_session_last_errno(session) + if code == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + opening = nil + let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to open SSH exec Channel") + if isConnectionError(code) { + failConnection(session, error: error) + } else { + finish(command, throwing: SSHCommandExecutionError.channelUnavailable(error)) + } + return true + } + opening = nil + command.execChannel = execChannel + command.phase = requestsAgentForwarding && buffer?.agentChannels != nil ? .forwarding : .starting + ready.insert(command.id) + return true + } + + private func advance(_ command: SSHExecChannel, session: OpaquePointer, deadline: UInt64) -> Bool { + // Release the caller promptly on cancellation/timeout; retain the channel and its + // lease until the nonblocking close/free state machine finishes on this queue. + if let cancellation = cancellationError(for: command), !command.completed { + command.completed = true + command.continuation.resume(throwing: command.terminalError ?? cancellation) + } + if let cancellation = cancellationError(for: command), command.terminalError == nil { + if agentRequestOwner == command.id { + // A cancelled, unanswered connection-wide request cannot safely + // be repeated on another channel: the peer may already have accepted it. + failConnection(session, error: cancellation) + return false + } + command.terminalError = cancellation + if command.execChannel == nil { + finish(command, throwing: cancellation) + return true + } + command.phase = .closing + } + guard let execChannel = command.execChannel else { return false } + switch command.phase { + case .opening: + return false + case .forwarding: + if agentForwardingRequested { + command.phase = .starting + ready.insert(command.id) + return true + } + guard agentRequestOwner == nil || agentRequestOwner == command.id else { return false } + agentRequestOwner = command.id + let result = libssh2_channel_request_auth_agent(execChannel) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + agentRequestOwner = nil + if result != 0 { + command.terminalError = SSHCommandExecutionError.channelUnavailable( + SSHConnectionOpener.connectionError(session, prefix: "Agent Forwarding request failed")) + command.phase = .closing + } else { + agentForwardingRequested = true + command.phase = .starting + } + ready.insert(command.id) + return true + case .starting: + let result = libssh2_channel_process_startup( + execChannel, "exec", 4, command.command.pointer, UInt32(command.command.count)) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + guard result == 0 else { + let code = libssh2_session_last_errno(session) + let error = SSHConnectionOpener.connectionError(session, prefix: "Unable to start remote command") + command.terminalError = SSHCommandExecutionError.channelUnavailable(error) + command.phase = .closing + if isConnectionError(code) { failConnection(session, error: error) } + ready.insert(command.id) + return true + } + command.started = true + command.phase = .running + ready.insert(command.id) + return true + case .running: + return advanceRunning(command, execChannel: execChannel, session: session, deadline: deadline) + case .closing: + let result = libssh2_channel_close(execChannel) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + if command.terminalError == nil { + command.exitCode = libssh2_channel_get_exit_status(execChannel) + command.signal = exitSignal(execChannel: execChannel, session: session) + } + command.phase = .freeing + ready.insert(command.id) + return true + case .freeing: + let result = libssh2_channel_free(execChannel) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + command.execChannel = nil + if let error = command.terminalError { + finish(command, throwing: error) + } else { + let stdout = String(decoding: command.stdout, as: UTF8.self) + let stderr = String(decoding: command.stderr, as: UTF8.self) + finish( + command, + returning: SSHExecResult( + stdout: stdout, stderr: stderr, + exitCode: command.exitCode, signal: command.signal, + stdoutTruncated: command.stdoutTruncated, + stderrTruncated: command.stderrTruncated)) + } + return true + } + } + + private func advanceRunning( + _ command: SSHExecChannel, execChannel: OpaquePointer, session: OpaquePointer, deadline: UInt64 + ) -> Bool { + var progressed = false + var remaining = SSHForwardingTuning.current.quantum + if !command.input.isEmpty { + let written = command.input.withFirstBytes { + libssh2_channel_write_ex( + execChannel, 0, $0.baseAddress!.assumingMemoryBound(to: CChar.self), $0.count) + } + if written > 0 { + command.input.consume(written) + remaining -= min(remaining, written) + progressed = true + } else if written == LIBSSH2_ERROR_EAGAIN { + blocked(session) + } else { + return fail(command, session: session, prefix: "Unable to write command input") + } + } + if command.input.isEmpty, !command.sentEOF { + let result = libssh2_channel_send_eof(execChannel) + if result == 0 { + command.sentEOF = true + progressed = true + } else if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + } else { + return fail(command, session: session, prefix: "Unable to finish command input") + } + } + for stream in [Int32(0), Int32(1)] { + while remaining > 0 && DispatchTime.now().uptimeNanoseconds < deadline { + let capacity = min(scratch.count, remaining) + let count = libssh2_channel_read_ex(execChannel, stream, &scratch, capacity) + if count > 0 { + progressed = true + remaining -= count + let chunk = Data(bytes: scratch, count: count) + do { + try appendDecoded(chunk, stream: stream, to: command) + } catch { + command.terminalError = error + command.phase = .closing + ready.insert(command.id) + return true + } + } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { + if count == LIBSSH2_ERROR_EAGAIN { blocked(session) } + break + } else { + return fail(command, session: session, prefix: "Unable to read command output") + } + } + } + if libssh2_channel_eof(execChannel) == 1 { + do { + try finishDecodedOutput(command) + } catch { + command.terminalError = error + } + command.phase = .closing + ready.insert(command.id) + return true + } + if !command.input.isEmpty || !command.sentEOF || libssh2_poll_channel_read(execChannel, 0) == 1 { + ready.insert(command.id) + } + return progressed + } + + private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws { + let isError = stream != 0 + let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes + let available = max(0, command.outputLimit - consumed) + let accepted = Data(chunk.prefix(available)) + if isError { + command.stderrRemoteBytes += accepted.count + if accepted.count < chunk.count { command.stderrTruncated = true } + } else { + command.stdoutRemoteBytes += accepted.count + if accepted.count < chunk.count { command.stdoutTruncated = true } + } + guard !accepted.isEmpty else { return } + let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data + guard !decoded.isEmpty else { return } + command.onOutput?(decoded, isError) + if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) } + } + + private func finishDecodedOutput(_ command: SSHExecChannel) throws { + if !command.stdoutTruncated { + let tail = try command.stdoutDecoder.finish().data + if !tail.isEmpty { + command.onOutput?(tail, false) + command.stdout.append(tail) + } + } + if !command.stderrTruncated { + let tail = try command.stderrDecoder.finish().data + if !tail.isEmpty { + command.onOutput?(tail, true) + command.stderr.append(tail) + } + } + } + + private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool { + let code = libssh2_session_last_errno(session) + let error = SSHConnectionOpener.connectionError(session, prefix: prefix) + if isConnectionError(code) { + failConnection(session, error: error) + } else { + command.terminalError = error + command.phase = .closing + ready.insert(command.id) + } + return true + } + + private func cancellationError(for command: SSHExecChannel) -> (any Error)? { + if command.cancellation.isCancelled { return CancellationError() } + if ContinuousClock.now >= command.deadline { + return TermindSSHError.library("The remote command timed out.") + } + return nil + } + + private func exitSignal(execChannel: OpaquePointer, session: OpaquePointer) -> String? { + var pointer: UnsafeMutablePointer? + var length = 0 + let result = libssh2_channel_get_exit_signal(execChannel, &pointer, &length, nil, nil, nil, nil) + guard result == 0, let pointer else { return nil } + defer { libssh2_free(session, pointer) } + return String( + decoding: UnsafeBufferPointer(start: pointer, count: length).map(UInt8.init(bitPattern:)), + as: UTF8.self) + } + + private func finish(_ command: SSHExecChannel, returning result: SSHExecResult) { + commands[command.id] = nil + if !command.completed { + command.completed = true + command.continuation.resume(returning: result) + } + if commands.isEmpty, !isClosingAll { didCloseLastChannel() } + } + + private func finish(_ command: SSHExecChannel, throwing error: any Error) { + commands[command.id] = nil + if !command.completed { + command.completed = true + command.continuation.resume(throwing: error) + } + if commands.isEmpty, !isClosingAll { didCloseLastChannel() } + } + + private func blocked(_ session: OpaquePointer) { + controlDirections |= libssh2_session_block_directions(session) + } + + private func isConnectionError(_ code: Int32) -> Bool { + [ + LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, + LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT, + LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_BAD_SOCKET, + ].contains(code) + } + +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index dfd39f5e..a8099b1b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -36,6 +36,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { public var shell: String? public var systemInfo: RemoteSystemInfo? public var homeDirectory: String? + public var historySource: ShellHistorySource? public var homeResolutionUnavailable = false var diagnostic: Diagnostic? } @@ -240,12 +241,13 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { var visible = preservedOutput visible.append(Data(buffer[buffer.index(after: lineEnd)...])) let homeDirectory = parseHomeDirectory(in: buffer) + let historySource = parseHistorySource(in: buffer) finish() return Update( visibleOutput: visible, isComplete: true, integrationInstalled: true, shell: detectedShell, systemInfo: detectedSystemInfo, - homeDirectory: homeDirectory, + homeDirectory: homeDirectory, historySource: historySource, diagnostic: .installed( late: installationStartedLate, homeResolved: homeDirectory != nil, systemInfoAvailable: detectedSystemInfo != nil @@ -253,6 +255,19 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { ) } + private func parseHistorySource(in data: Data) -> ShellHistorySource? { + guard let shell = detectedShell.flatMap(ShellHistorySource.Shell.init(rawValue:)) else { return nil } + let prefix = Data("__TRMD_\(token)_HISTORY__".utf8) + guard let start = data.range(of: prefix)?.upperBound, + let end = data[start...].firstIndex(of: 0x0A) + else { return nil } + var payload = Data(data[start.. String? { let prefix = Data("__TRMD_\(token)_HOME__".utf8) guard let prefixRange = data.range(of: prefix), @@ -367,7 +382,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { isComplete: update.isComplete, integrationInstalled: update.integrationInstalled, shell: update.shell, systemInfo: update.systemInfo, - homeDirectory: update.homeDirectory, + homeDirectory: update.homeDirectory, historySource: update.historySource, homeResolutionUnavailable: update.homeResolutionUnavailable, diagnostic: update.diagnostic ) @@ -429,6 +444,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { private func installationCommand(for shell: String?) -> Data? { let marker = "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'" + let historyMarker = + "__termind_history_path=\"${HISTFILE-}\"; if [[ -n \"$__termind_history_path\" && \"$__termind_history_path\" != /* ]]; then __termind_history_path=\"$PWD/$__termind_history_path\"; fi; builtin printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' \"$(__termind_encode \"$__termind_history_path\")\"; unset __termind_history_path; " let command: String switch shell { case "bash": @@ -463,17 +480,17 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { "fi;", "fi;", "fi;", - "\(marker);", + "\(historyMarker)\(marker);", "__termind_report_prompt", ].joined(separator: " ") + "\n" case "zsh": // Unlike bash, zsh reserves `status` as a read-only special parameter. // Assigning it aborts this function and the remaining prompt hooks. command = - " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n" + " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(historyMarker)\(marker); __termind_report_prompt\n" case "fish": command = - " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" + " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; set -l __termind_hname fish; if set -q fish_history; set __termind_hname \"$fish_history\"; end; set -l __termind_hbase \"$HOME/.local/share\"; if set -q XDG_DATA_HOME; and test -n \"$XDG_DATA_HOME\"; set __termind_hbase \"$XDG_DATA_HOME\"; end; set -l __termind_hpath ''; if test -n \"$__termind_hname\"; set __termind_hpath \"$__termind_hbase/fish/$__termind_hname\"_history; end; printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' (string escape --style=url -- \"$__termind_hpath\"); set -e __termind_hname __termind_hbase __termind_hpath; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" default: return nil } @@ -765,6 +782,19 @@ public actor SSHPTYChannel { try await worker.openSFTPChannel() } + public func executeHistoryRead(_ command: String, outputLimit: Int) async throws -> SSHExecResult { + let cancellation = TransferCancellationToken() + return try await withTaskCancellationHandler { + try await worker.executeHistoryRead(command, outputLimit: outputLimit, cancellation: cancellation) + } onCancel: { + cancellation.cancel() + } + } + + public func historySource() async throws -> ShellHistorySource? { + try await worker.historySource() + } + public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { worker.resize( columns: columns, rows: rows, @@ -806,6 +836,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var workingDirectoryFilter: RemoteOSC7CharsetFilter? private var didReportExit = false private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner! + private var execChannelOwner: SSHMultiplexedExecChannelOwner! + private var detectedHistorySource: ShellHistorySource? init( output: @escaping SSHPTYChannel.OutputHandler, @@ -821,6 +853,26 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { self.logger = logger shellIntegrationLifecycleLogger = RemoteShellIntegrationLifecycleLogger(logger: logger) self.maxSessionsPolicy = maxSessionsPolicy + execChannelOwner = SSHMultiplexedExecChannelOwner( + queue: queue, logger: logger, requestsAgentForwarding: false, + context: { [weak self] in + guard let self, let connection = self.connection, let buffer = self.buffer, + let channelBudget = self.channelBudget + else { return nil } + return .init( + connection: connection, buffer: buffer, channelBudget: channelBudget, + charset: self.charset, hostID: self.hostID) + }, + schedulePump: { [weak self] in self?.pump() }, + connectionFailed: { [weak self] error in + guard let self else { return } + self.reportExit(.failure((error as? TermindSSHError) ?? .disconnected)) + self.cleanup() + }, + didCloseLastChannel: { [weak self] in + guard let self, self.ptyChannel == nil, self.sftpChannelOwner.isEmptyOnQueue else { return } + self.cleanup() + }) sftpChannelOwner = SSHMultiplexedSFTPChannelOwner( queue: queue, context: { [weak self] in @@ -836,7 +888,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { self?.updateSharedConnectionEvents(session: session, buffer: buffer) }, didCloseLastChannel: { [weak self] in - guard let self, self.ptyChannel == nil else { return } + guard let self, self.ptyChannel == nil, self.execChannelOwner.isEmptyOnQueue else { return } self.cleanup() }) } @@ -1568,6 +1620,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { socketEvents?.update(read: false, write: true) return } + execChannelOwner.wakeAllOnQueue() + let execProgress = execChannelOwner.advanceOnQueue( + deadline: min( + deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 4)) + guard self.connection != nil else { return } + if execProgress { queue.async { [weak self] in self?.pump() } } var agentProgress = buffer.agentChannels?.advance( deadline: min( @@ -1680,6 +1738,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { if let command = update.command { pendingWrites.append(command) } + if let source = update.historySource { detectedHistorySource = source } if update.integrationInstalled { shellIntegrationHandler( .available( @@ -1756,6 +1815,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func closeTerminalSession() { + execChannelOwner.cancelAllOnQueue() + detectedHistorySource = nil buffer?.agentChannels?.shutdown() finishCharsetOutput() pendingWrites.removeAll() @@ -1775,7 +1836,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ptyChannelLease?.release() ptyChannelLease = nil } - if sftpChannelOwner.isEmptyOnQueue { cleanup() } + if sftpChannelOwner.isEmptyOnQueue, execChannelOwner.isEmptyOnQueue { + cleanup() + } else { + queue.async { [weak self] in self?.pump() } + } } private func cleanup() { @@ -1799,6 +1864,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { _ = libssh2_channel_free(ptyChannel) self.ptyChannel = nil } + execChannelOwner.closeAllOnQueue(error: CancellationError()) + detectedHistorySource = nil sftpChannelOwner.closeAllOnQueue() ptyChannelLease?.release() ptyChannelLease = nil @@ -1959,6 +2026,27 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } nonisolated extension SSHPTYChannelWorker { + fileprivate func executeHistoryRead( + _ command: String, outputLimit: Int, cancellation: TransferCancellationToken + ) async throws -> SSHExecResult { + try await execChannelOwner.execute( + command, workingDirectory: nil, stdin: nil, timeout: 10, + outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false) + } + + fileprivate func historySource() async throws -> ShellHistorySource? { + let deadline = ContinuousClock.now.advanced(by: .seconds(6)) + while true { + try Task.checkCancellation() + let (source, pending) = try await queue.perform { [self] in + guard connection != nil else { throw TermindSSHError.disconnected } + return (detectedHistorySource, shellIntegration != nil) + } + if !pending || source != nil || ContinuousClock.now >= deadline { return source } + try await Task.sleep(for: .milliseconds(50)) + } + } + fileprivate func openSFTPChannel() async throws -> any SFTPChannelProtocol { try await sftpChannelOwner.openChannel() } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 1b542a8a..6f642b0d 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -125,8 +125,6 @@ final class MobileAppModel { let sshSettings: SSHSettingsModel let sftpSettings: SFTPSettingsModel let workspaceSettings: WorkspaceSettingsModel - let commandHistory: CommandHistoryModel - let sftpHistory: SFTPHistoryModel let connectionHistory: ConnectionHistoryModel let devices: DeviceDirectoryModel let vaultSync: VaultSyncCoordinator? @@ -247,10 +245,6 @@ final class MobileAppModel { sftpSettings = SFTPSettingsModel(persistence: settingsPersistence) workspaceSettings = WorkspaceSettingsModel(persistence: settingsPersistence) connectionCoordinator.updateSSHPreferences(sshSettings.preferences) - commandHistory = CommandHistoryModel( - repository: SwiftDataCommandHistoryRepository(context: ModelContext(container))) - sftpHistory = SFTPHistoryModel( - repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container))) let historyPersistence = HistorySyncPersistence(context: vaultContext) try historyPersistence.prepare() let connectionHistoryRepository = SwiftDataConnectionHistoryRepository(context: vaultContext) @@ -378,8 +372,6 @@ final class MobileAppModel { closeWorkspaces: { [weak self] payload in guard let self else { return } closeWorkspaces(for: payload.hostID) - commandHistory.clear(for: payload.hostID) - sftpHistory.clear(for: payload.hostID) connectionHistory.clear(for: payload.hostID) library.projectHostDeletion(payload.hostID) }, @@ -672,8 +664,6 @@ final class MobileAppModel { } if change.isRemoved { closeWorkspaces(for: change.id) - commandHistory.clear(for: change.id) - sftpHistory.clear(for: change.id) connectionHistory.clear(for: change.id) metrics.remove(change.id) } diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index 302a6fb9..ff3315f5 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -183,8 +183,7 @@ final class MobileWorkspaceSessionModel { hostID: host.id, title: host.name, kind: kind), hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalHighlightSettings: app.terminalHighlightSettings, - terminalSettings: app.terminalSettings, commandHistory: app.commandHistory, - sftpHistory: app.sftpHistory, connectionServices: app.workspaceConnectionServices, + terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, transientHost: host, connectionRoute: request.kind == .telnet ? nil : route, usernameHint: request.username, @@ -238,7 +237,7 @@ final class MobileWorkspaceSessionModel { if let workspace = workspaces.first(where: { $0.id == id }) { workspace.didChangeState = nil workspace.didRequestClose = nil - workspace.disconnect() + workspace.close() } workspaces.removeAll { $0.id == id } savedWorkspaceDescriptors.removeAll { $0.id == id } @@ -264,7 +263,7 @@ final class MobileWorkspaceSessionModel { app.workspaceDidClose(workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil - workspace.disconnect() + workspace.close() } } @@ -280,7 +279,7 @@ final class MobileWorkspaceSessionModel { app.workspaceDidClose(workspace.id) workspace.didChangeState = nil workspace.didRequestClose = nil - workspace.disconnect() + workspace.close() } } @@ -297,8 +296,7 @@ final class MobileWorkspaceSessionModel { let workspace = MobileWorkspace( descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalHighlightSettings: app.terminalHighlightSettings, - terminalSettings: app.terminalSettings, commandHistory: app.commandHistory, sftpHistory: app.sftpHistory, - connectionServices: app.workspaceConnectionServices) + terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices) addWorkspaces([workspace]) return workspace } @@ -338,7 +336,6 @@ final class MobileWorkspaceSessionModel { hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalHighlightSettings: app.terminalHighlightSettings, terminalSettings: app.terminalSettings, - commandHistory: app.commandHistory, sftpHistory: app.sftpHistory, connectionServices: app.workspaceConnectionServices, initialCommand: initialCommand, recordsInitialCommandInHistory: recordsInitialCommandInHistory, diff --git a/TFI/Application/Transfers/MobileSFTPChannelModel.swift b/TFI/Application/Transfers/MobileSFTPChannelModel.swift index d13a53b5..736ea929 100644 --- a/TFI/Application/Transfers/MobileSFTPChannelModel.swift +++ b/TFI/Application/Transfers/MobileSFTPChannelModel.swift @@ -44,15 +44,13 @@ final class MobileSFTPChannelModel { } @ObservationIgnored private var fileSystem: (any RemoteFileSystem)? - @ObservationIgnored private let hostID: Host.ID? @ObservationIgnored private let history: SFTPHistoryModel? @ObservationIgnored private var connectionGeneration: UUID? @ObservationIgnored private var directoryRequest = UUID() @ObservationIgnored private var handlesConnectionError: ((any Error) -> Bool)? @ObservationIgnored private var didChangePath: (() -> Void)? - init(hostID: Host.ID? = nil, history: SFTPHistoryModel? = nil) { - self.hostID = hostID + init(history: SFTPHistoryModel? = nil) { self.history = history } @@ -239,7 +237,6 @@ final class MobileSFTPChannelModel { } private func recordDirectory(_ path: String) { - guard let hostID else { return } - history?.record(kind: .directory, path: path, for: hostID) + history?.record(kind: .directory, path: path) } } diff --git a/TFI/Features/SFTP/MobileSFTPHistoryView.swift b/TFI/Features/SFTP/MobileSFTPHistoryView.swift index 39aceeaf..daff5926 100644 --- a/TFI/Features/SFTP/MobileSFTPHistoryView.swift +++ b/TFI/Features/SFTP/MobileSFTPHistoryView.swift @@ -9,12 +9,11 @@ struct MobileSFTPHistoryView: View { @Environment(\.dismiss) private var dismiss @State private var query = "" - @State private var clearing = false private var entries: [SFTPHistoryEntry] { _ = workspace.sftpHistory.revision return MobileHistoryPresentation.limited( - workspace.sftpHistory.entries(for: workspace.descriptor.hostID, matching: query) + workspace.sftpHistory.entries(matching: query) ) } @@ -43,52 +42,18 @@ struct MobileSFTPHistoryView: View { reopen(entry) } .disabled(!workspace.connected) - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { - workspace.sftpHistory.delete(entry.id) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { - workspace.sftpHistory.delete(entry.id) - } } } .listStyle(.insetGrouped) } } .background(Color(uiColor: .systemGroupedBackground)) - .safeAreaInset(edge: .bottom) { - if let error = workspace.sftpHistory.storageError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.vertical, 8) - .background(.bar) - } - } .searchable(text: $query.singleLineText, prompt: AppStrings.Common.searchHistory.swiftUIKey) .navigationTitle(AppStrings.Common.history.swiftUIKey) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(AppStrings.Common.done.swiftUIKey, systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly) } - ToolbarItem(placement: .primaryAction) { - Button(AppStrings.Common.clear.swiftUIKey, systemImage: "trash", role: .destructive) { - clearing = true - } - .labelStyle(.iconOnly) - .disabled(entries.isEmpty) - } - } - .confirmationDialog( - AppStrings.SFTP.clearFileHistoryForThisHostConfirmation, isPresented: $clearing, - titleVisibility: .visible - ) { - Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) { - workspace.sftpHistory.clear(for: workspace.descriptor.hostID) - } } } .mobileSheetPresentation(compact: true) diff --git a/TFI/Features/Terminal/MobileCommandHistoryView.swift b/TFI/Features/Terminal/MobileCommandHistoryView.swift index 2be3d095..4cafa1fc 100644 --- a/TFI/Features/Terminal/MobileCommandHistoryView.swift +++ b/TFI/Features/Terminal/MobileCommandHistoryView.swift @@ -18,14 +18,13 @@ struct MobileCommandHistoryView: View { @Environment(\.dismiss) private var dismiss @Environment(\.locale) private var locale @State private var query = "" - @State private var clearing = false @State private var snippetEntry: CommandHistoryEntry? @State private var snippetName = "" private var entries: [CommandHistoryEntry] { _ = workspace.commandHistory.revision return MobileHistoryPresentation.limited( - workspace.commandHistory.entries(for: workspace.descriptor.hostID, matching: query) + workspace.commandHistory.entries(matching: query) ) } @@ -56,51 +55,18 @@ struct MobileCommandHistoryView: View { Button(AppStrings.Snippets.saveAsSnippet.swiftUIKey, systemImage: "curlybraces") { beginSavingSnippet(entry) } - Divider() - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { - workspace.commandHistory.delete(entry.id) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { - workspace.commandHistory.delete(entry.id) - } } } .listStyle(.insetGrouped) } } .background(Color(uiColor: .systemGroupedBackground)) - .safeAreaInset(edge: .bottom) { - if let error = workspace.commandHistory.storageError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.vertical, 8) - .background(.bar) - } - } .searchable(text: $query.singleLineText) .navigationTitle(AppStrings.Common.history.swiftUIKey) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(AppStrings.Common.done.swiftUIKey, systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly) } - ToolbarItem(placement: .primaryAction) { - Button(AppStrings.Common.clear.swiftUIKey, systemImage: "trash", role: .destructive) { - clearing = true - }.labelStyle(.iconOnly) - } - } - .confirmationDialog( - AppStrings.Terminal.clearHistoryConfirmation, isPresented: $clearing, - titleVisibility: .visible - ) { - Button(AppStrings.Common.clear.swiftUIKey, role: .destructive) { - workspace.commandHistory.clear(for: workspace.descriptor.hostID) - } } .alert( AppStrings.Snippets.saveAsSnippet, @@ -133,10 +99,10 @@ struct MobileCommandHistoryView: View { .font(.body.monospaced()) .lineLimit(1) .textSelection(.enabled) - Text(CommandHistoryPresentation.subtitle(for: entry, locale: locale)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + let subtitle = CommandHistoryPresentation.subtitle(for: entry, locale: locale) + if !subtitle.isEmpty { + Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } } Spacer(minLength: 8) MobileTerminalRiskIndicator(assessment: CommandRiskClassifier.assess(entry.command)) diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 224450ba..320de80a 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -167,7 +167,9 @@ final class MobileWorkspace: Identifiable { private var sshEventContinuation: AsyncStream.Continuation? private var sshEventTask: Task? private var attempt: Task? - private var generation = UUID() + private var generation = UUID() { + didSet { commandHistory.cancelLoading() } + } private let hosts: any RemoteSessionHostStore private let connectionServices: MobileWorkspaceConnectionServices var securityKeySigning: SecurityKeySigningCoordinator { connection.securityKeySigning } @@ -248,8 +250,6 @@ final class MobileWorkspace: Identifiable { sftpEdits: SFTPEditService? = nil, terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(), terminalSettings: TerminalSettingsModel = TerminalSettingsModel(), - commandHistory: CommandHistoryModel = CommandHistoryModel(), - sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), connectionServices: MobileWorkspaceConnectionServices = .live, reconnectController: SessionReconnectController = SessionReconnectController(), initialCommand: String? = nil, @@ -265,9 +265,9 @@ final class MobileWorkspace: Identifiable { ) { self.terminalHighlightSettings = terminalHighlightSettings self.terminalSettings = terminalSettings - self.commandHistory = commandHistory - self.sftpHistory = sftpHistory - sftpChannel = MobileSFTPChannelModel(hostID: descriptor.hostID, history: sftpHistory) + commandHistory = CommandHistoryModel(sessionID: descriptor.id) + sftpHistory = SFTPHistoryModel(sessionID: descriptor.id) + sftpChannel = MobileSFTPChannelModel(history: sftpHistory) self.transfers = transfers self.sftpEdits = sftpEdits self.descriptor = descriptor @@ -606,7 +606,7 @@ final class MobileWorkspace: Identifiable { commandExcludedFromHistory = nil activeHistoryID = nil } else { - activeHistoryID = commandHistory.record(command, for: descriptor.hostID)?.id + activeHistoryID = commandHistory.record(command)?.id } case .commandFinished(let status, let duration): // CommandFinished is emitted from the prompt hook immediately before @@ -620,7 +620,7 @@ final class MobileWorkspace: Identifiable { submitPendingInitialCommandIfReady() guard let id = activeHistoryID else { return } commandHistory.recordCompletion( - for: id, hostID: descriptor.hostID, exitStatus: status, duration: duration) + for: id, exitStatus: status, duration: duration) activeHistoryID = nil case .draft(let draft): commandDraft.receive(draft) @@ -864,6 +864,10 @@ final class MobileWorkspace: Identifiable { proxySubmissions.removeAll() let wasReconnecting = connection.markConnected() connected = true + if let ssh { + commandHistory.load( + using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil) + } status = String(localized: AppStrings.Connections.connected) journey.succeed() if wasReconnecting, descriptor.kind == .ssh { @@ -1241,7 +1245,14 @@ final class MobileWorkspace: Identifiable { } } + func close() { + commandHistory.close() + sftpHistory.close() + disconnect() + } + func disconnect() { + commandHistory.cancelLoading() commandMonitor.endConnection() cancelPendingUpload() stopSSHEventConsumer() @@ -1601,9 +1612,9 @@ final class MobileWorkspace: Identifiable { connection: .context(transferContext), homeDirectory: sftpChannel.homeDirectory, coordinator: transfers - ) { [sftpHistory, hostID = descriptor.hostID] path, state in + ) { [sftpHistory] path, state in guard state == .completed else { return } - sftpHistory.record(kind: .file, path: path, for: hostID) + sftpHistory.record(kind: .file, path: path) } } catch { operationError = error.localizedDescription } return @@ -1741,7 +1752,7 @@ final class MobileWorkspace: Identifiable { file, hostID: descriptor.hostID, connection: .context(transferContext), client: sftp, homeDirectory: sftpChannel.homeDirectory) if connectionOrigin.allowsPersistence { - sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) + sftpHistory.record(kind: .file, path: file.path) } completion(url) } catch { operationError = error.localizedDescription } @@ -1761,10 +1772,10 @@ final class MobileWorkspace: Identifiable { remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID, connection: .context(transferContext), scopedRoot: directory, overwrite: false, homeDirectory: sftpChannel.homeDirectory - ) { [sftpHistory, hostID = descriptor.hostID, path = file.path] state in + ) { [sftpHistory, path = file.path] state in guard state == .completed else { return } if self.connectionOrigin.allowsPersistence { - sftpHistory.record(kind: .file, path: path, for: hostID) + sftpHistory.record(kind: .file, path: path) } completion(localURL) } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index d7748a9e..b8dc8a10 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -92,21 +92,20 @@ struct MobileAppTests { #expect(first.assistant.conversation.composer.draft.isEmpty) } - @Test func historyModelsUseTheSharedSwiftDataContainer() throws { + @Test func historyBelongsToWorkspaceAndIsReleasedOnClose() throws { let app = try MobileAppModel(inMemory: true) - let hostID = UUID() - app.commandHistory.record("echo persisted", for: hostID) - app.sftpHistory.record(kind: .directory, path: "/srv/app", for: hostID) - - let commands = CommandHistoryModel( - repository: SwiftDataCommandHistoryRepository(context: ModelContext(app.container)) - ) - let files = SFTPHistoryModel( - repository: SwiftDataSFTPHistoryRepository(context: ModelContext(app.container)) - ) - - #expect(commands.entries(for: hostID).map(\.command) == ["echo persisted"]) - #expect(files.entries(for: hostID).map(\.path) == ["/srv/app"]) + let host = TermindDomain.Host(name: "Host", host: "example.invalid") + let first = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library) + let second = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library) + first.commandHistory.record("pwd") + first.sftpHistory.record(kind: .directory, path: "/srv") + #expect(second.commandHistory.entries().isEmpty) + #expect(second.sftpHistory.entries().isEmpty) + first.close() + #expect(first.commandHistory.entries().isEmpty) + #expect(first.sftpHistory.entries().isEmpty) } @Test func clearingRecentsPreservesCommandAndFileHistory() throws { @@ -115,14 +114,16 @@ struct MobileAppTests { let host = TermindDomain.Host(name: "Recent", host: "recent.example.invalid") app.library.upsert(host) _ = app.connectionHistory.record(kind: .ssh, for: host.id) - app.commandHistory.record("pwd", for: host.id) - app.sftpHistory.record(kind: .directory, path: "/srv", for: host.id) + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library) + workspace.commandHistory.record("pwd") + workspace.sftpHistory.record(kind: .directory, path: "/srv") app.connectionHistory.clear(for: host.id) #expect(app.connectionHistory.entries(for: host.id).isEmpty) - #expect(app.commandHistory.entries(for: host.id).map(\.command) == ["pwd"]) - #expect(app.sftpHistory.entries(for: host.id).map(\.path) == ["/srv"]) + #expect(workspace.commandHistory.entries().map(\.command) == ["pwd"]) + #expect(workspace.sftpHistory.entries().map(\.path) == ["/srv"]) } } diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 4b54a244..36f9fc1a 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -104,11 +104,10 @@ struct MobileCommandGeneratorTests { let settings = TerminalSettingsModel() #expect(settings.set(global, for: \.shellIntegration)) let connections = MobileSSHCommandConnectionBox() - let history = CommandHistoryModel() let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), - hosts: library, terminalSettings: settings, commandHistory: history, - connectionServices: connections.services) + hosts: library, terminalSettings: settings, connectionServices: connections.services) + let history = workspace.commandHistory defer { workspace.disconnect() } workspace.connect() #expect(await eventually { workspace.connected }) @@ -125,7 +124,7 @@ struct MobileCommandGeneratorTests { #expect((workspace.sameDirectoryLaunch != nil) == expected) workspace.receiveShellEvent(.commandStarted("pwd")) workspace.receiveShellEvent(.commandFinished(0, duration: 0.1)) - #expect(history.entries(for: host.id).count == (expected ? 1 : 0)) + #expect(history.entries().count == (expected ? 1 : 0)) #expect(connections.sessionCount == 1) workspace.disconnect() @@ -352,8 +351,8 @@ struct MobileCommandGeneratorTests { ]) #expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) }) - #expect(await eventually { app.commandHistory.entries(for: host.id).count == 1 }) - #expect(app.commandHistory.entries(for: host.id).first?.command == "pwd") + #expect(await eventually { workspace.commandHistory.entries().count == 1 }) + #expect(workspace.commandHistory.entries().first?.command == "pwd") #expect(workspace.pendingInitialCommand == nil) } @@ -430,13 +429,12 @@ struct MobileCommandGeneratorTests { @Test func productionCallbacksStayOrderedAndIgnoreAnOldSession() async throws { let (host, library) = try commandFixture() let connections = MobileSSHCommandConnectionBox() - let history = CommandHistoryModel() let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: library, - commandHistory: history, connectionServices: connections.services ) + let history = workspace.commandHistory defer { workspace.disconnect() } workspace.connect() #expect(await eventually { workspace.connected && connections.sessionCount == 1 }) @@ -448,7 +446,7 @@ struct MobileCommandGeneratorTests { .commandStarted("pwd"), ]) - #expect(await eventually { history.entries(for: host.id).count == 1 }) + #expect(await eventually { history.entries().count == 1 }) #expect(!workspace.canGenerateFromCurrentDraft) await connections.sendShellEvents([ @@ -456,7 +454,7 @@ struct MobileCommandGeneratorTests { ]) #expect(await eventually { workspace.canGenerateFromCurrentDraft }) - let entry = try #require(history.entries(for: host.id).first) + let entry = try #require(history.entries().first) #expect(entry.command == "pwd") #expect(entry.exitStatus == 0) #expect(entry.duration == 0.25) @@ -474,7 +472,7 @@ struct MobileCommandGeneratorTests { ], session: 1) #expect(await eventually { workspace.canGenerateFromCurrentDraft }) - #expect(!history.entries(for: host.id).contains { $0.command == "stale" }) + #expect(!history.entries().contains { $0.command == "stale" }) } @Test func disabledIntegrationIsPassedToTheTransportAndRemainsUnavailable() async throws { @@ -506,13 +504,12 @@ struct MobileCommandGeneratorTests { @Test func unsupportedShellRemainsUnavailableAfterOrderedPromptEvents() async throws { let (host, library) = try commandFixture() let connections = MobileSSHCommandConnectionBox() - let history = CommandHistoryModel() let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: library, - commandHistory: history, connectionServices: connections.services ) + let history = workspace.commandHistory defer { workspace.disconnect() } workspace.connect() #expect(await eventually { workspace.connected }) @@ -525,7 +522,7 @@ struct MobileCommandGeneratorTests { .promptReady, ]) - #expect(await eventually { history.entries(for: host.id).count == 1 }) + #expect(await eventually { history.entries().count == 1 }) #expect(!workspace.canGenerateFromCurrentDraft) let generator = MobileCommandGeneratorStub(command: "echo generated") workspace.generateCommand(using: generator) @@ -1079,14 +1076,13 @@ struct MobileUploadBatchTests { connectSFTP: { _, _, _ in sessionClient }, writeSSH: { _, _ in } ) - let history = SFTPHistoryModel() let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp), hosts: library, transfers: transfers, - sftpHistory: history, connectionServices: services ) + let history = workspace.sftpHistory defer { workspace.disconnect() } workspace.connect() #expect(await eventually { workspace.connected }) @@ -1094,7 +1090,7 @@ struct MobileUploadBatchTests { #expect(await workspace.reopenHistoryFile(at: report.path)) #expect( await eventually { - history.entries(for: host.id).contains { $0.kind == .file && $0.path == report.path } + history.entries().contains { $0.kind == .file && $0.path == report.path } }) #expect(await workspace.reopenHistoryFile(at: broken.path)) @@ -1105,12 +1101,12 @@ struct MobileUploadBatchTests { if case .failed = state { return true } return false }) - #expect(!history.entries(for: host.id).contains { $0.path == broken.path }) + #expect(!history.entries().contains { $0.path == broken.path }) #expect(transfers.sftpTransfers.allSatisfy { $0.sourceHomeDirectory == "/home/test" }) #expect(!(await workspace.reopenHistoryFile(at: "/srv/missing.txt"))) #expect(!(await workspace.reopenHistoryFile(at: changedDirectory.path))) - #expect(!history.entries(for: host.id).contains { $0.path == changedDirectory.path }) + #expect(!history.entries().contains { $0.path == changedDirectory.path }) } @Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws { diff --git a/TFITests/MobileSFTPChannelTests.swift b/TFITests/MobileSFTPChannelTests.swift index 107c079d..0881d578 100644 --- a/TFITests/MobileSFTPChannelTests.swift +++ b/TFITests/MobileSFTPChannelTests.swift @@ -165,31 +165,29 @@ struct MobileSFTPChannelTests { #expect(mobileSFTPListItems(path: "/", files: sorted).count == sorted.count) } - @Test func successfulDirectoriesRefreshPersistentHistoryAndFailuresDoNot() async throws { - let hostID = UUID() + @Test func successfulDirectoriesRefreshSessionHistoryAndFailuresDoNot() async throws { let history = SFTPHistoryModel() let fileSystem = MobileBrowserFileSystem( listings: [ "/home": [], "/srv": [], ]) - let channel = MobileSFTPChannelModel(hostID: hostID, history: history) + let channel = MobileSFTPChannelModel(history: history) try await attach(channel, to: fileSystem, path: "/home/") await channel.openDirectory("/srv") await channel.reload() - #expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"]) + #expect(history.entries().map(\.path) == ["/srv", "/home"]) await channel.openDirectory("/missing") - #expect(history.entries(for: hostID).map(\.path) == ["/srv", "/home"]) + #expect(history.entries().map(\.path) == ["/srv", "/home"]) } @Test func staleDirectoryResponseDoesNotCreateHistory() async throws { - let hostID = UUID() let history = SFTPHistoryModel() let staleFileSystem = MobileDeferredFileSystem() let currentFileSystem = MobileBrowserFileSystem(listings: ["/current": []]) - let channel = MobileSFTPChannelModel(hostID: hostID, history: history) + let channel = MobileSFTPChannelModel(history: history) let staleGeneration = UUID() let staleAttach = Task { @@ -213,7 +211,7 @@ struct MobileSFTPChannelTests { await staleAttach.value #expect(channel.path == "/current") - #expect(history.entries(for: hostID).map(\.path) == ["/current"]) + #expect(history.entries().map(\.path) == ["/current"]) } private func attach( diff --git a/TFITests/MobileSFTPHistoryTests.swift b/TFITests/MobileSFTPHistoryTests.swift index 4bb879cc..2777e802 100644 --- a/TFITests/MobileSFTPHistoryTests.swift +++ b/TFITests/MobileSFTPHistoryTests.swift @@ -6,29 +6,31 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1")) @MainActor struct MobileSFTPHistoryTests { - @Test func deletingAHistoryEntryUpdatesThePresentedList() async throws { + @Test func appendingAHistoryEntryUpdatesThePresentedList() async throws { let host = TermindDomain.Host(name: "Test", host: "example.invalid") let library = HostLibraryModel(hosts: [host]) - let history = SFTPHistoryModel() - history.record(kind: .directory, path: "/srv", for: host.id) - history.record(kind: .file, path: "/srv/report.txt", for: host.id) let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp), - hosts: library, - sftpHistory: history - ) + hosts: library) + let history = workspace.sftpHistory + history.record(kind: .directory, path: "/srv") + history.record(kind: .file, path: "/srv/report.txt") let controller = UIHostingController( rootView: MobileSFTPHistoryView(workspace: workspace, open: { _ in }) ) - let window = UIWindow(frame: UIScreen.main.bounds) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previousWindow = scene.keyWindow + let window = UIWindow(windowScene: scene) + window.windowLevel = .alert + 1 window.rootViewController = controller window.makeKeyAndVisible() defer { window.isHidden = true window.rootViewController = nil + previousWindow?.makeKey() } controller.view.frame = window.bounds controller.view.setNeedsLayout() @@ -36,11 +38,13 @@ struct MobileSFTPHistoryTests { try await Task.sleep(for: .milliseconds(300)) #expect(visibleCells(in: controller.view).count == 2) - let newest = try #require(history.entries(for: host.id).first) - history.delete(newest.id) - try await Task.sleep(for: .milliseconds(300)) - - #expect(visibleCells(in: controller.view).count == 1) + history.record(kind: .directory, path: "/new") + for _ in 0..<100 where visibleCells(in: controller.view).count != 3 { + try await Task.sleep(for: .milliseconds(20)) + controller.view.layoutIfNeeded() + } + #expect(history.entries().map(\.path) == ["/new", "/srv/report.txt", "/srv"]) + #expect(visibleCells(in: controller.view).count == 3) } private func visibleCells(in root: UIView) -> [UIView] { diff --git a/TFITests/MobileSettingsPortTests.swift b/TFITests/MobileSettingsPortTests.swift index 45e8cb0a..adfbe263 100644 --- a/TFITests/MobileSettingsPortTests.swift +++ b/TFITests/MobileSettingsPortTests.swift @@ -159,23 +159,23 @@ struct MobileSettingsPortTests { app.library.upsert(host) let workspace = MobileWorkspace( descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), - hosts: app.library, terminalSettings: app.terminalSettings, commandHistory: app.commandHistory) + hosts: app.library, terminalSettings: app.terminalSettings) workspace.receiveShellEvent(.commandStarted("not-authenticated")) - #expect(app.commandHistory.entries(for: host.id).isEmpty) + #expect(workspace.commandHistory.entries().isEmpty) workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil)) workspace.receiveShellEvent( .commandStarted("echo hello \\\n world\nprintf retained") ) workspace.receiveShellEvent(.commandFinished(0, duration: 1.25)) workspace.receiveShellEvent(.commandFinished(1, duration: 9)) - let entry = try #require(app.commandHistory.entries(for: host.id).first) + let entry = try #require(workspace.commandHistory.entries().first) #expect(entry.command == "echo hello world\nprintf retained") #expect(entry.exitStatus == 0) #expect(entry.duration == 1.25) workspace.receiveShellEvent(.commandStarted("interrupted")) workspace.disconnect() workspace.receiveShellEvent(.commandFinished(0, duration: 1)) - #expect(app.commandHistory.entries(for: host.id).first?.exitStatus == nil) + #expect(workspace.commandHistory.entries().first?.exitStatus == nil) } @Test func findQuerySurvivesDismissalAndNavigationReopensSearch() throws { @@ -201,10 +201,12 @@ struct MobileSettingsPortTests { let app = try MobileAppModel(inMemory: true) let host = TermindDomain.Host(name: "Test", host: "example.invalid") app.library.upsert(host) - app.commandHistory.record("test", for: host.id) + let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh)) + let workspace = try #require(app.workspaces.first { $0.id == id }) + workspace.commandHistory.record("test") app.applyRemoteVault(.init(), keys: [VaultRecordKey(kind: .host, id: host.id)]) #expect(app.library.host(host.id) == nil) - #expect(app.commandHistory.entries(for: host.id).isEmpty) + #expect(workspace.commandHistory.entries().isEmpty) } } diff --git a/TFITests/MobileTerminalListTests.swift b/TFITests/MobileTerminalListTests.swift index 6b6de2d9..56fad7ad 100644 --- a/TFITests/MobileTerminalListTests.swift +++ b/TFITests/MobileTerminalListTests.swift @@ -6,47 +6,32 @@ import UIKit @testable import TermindIOS -@Suite(.serialized) @MainActor +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1")) @MainActor struct MobileTerminalListTests { - @Test func deletingAnEndpointUpdatesTheNativeHistoryList() async throws { + @Test func appendingACommandUpdatesTheNativeHistoryList() async throws { let app = try MobileAppModel(inMemory: true) let host = TermindDomain.Host(name: "Test", host: "example.invalid") - app.library.upsert(host) - app.commandHistory.record("pwd", for: host.id) - app.commandHistory.record("whoami", for: host.id) let workspace = MobileWorkspace( - descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), - hosts: app.library, - commandHistory: app.commandHistory) - + descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library) + workspace.commandHistory.record("pwd") try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in - #expect(nativeVisibleCells(in: root.view).count == 2) - - let newest = try #require(app.commandHistory.entries(for: host.id).first) - app.commandHistory.delete(newest.id) - try await Task.sleep(for: .milliseconds(300)) - #expect(nativeVisibleCells(in: root.view).count == 1) + workspace.commandHistory.record("whoami") + try await Task.sleep(for: .milliseconds(300)) + #expect(nativeVisibleCells(in: root.view).count == 2) } } @Test func retainedHistoryKeepsOnlyTheNativeViewportMounted() async throws { let app = try MobileAppModel(inMemory: true) let host = TermindDomain.Host(name: "Test", host: "example.invalid") - app.library.upsert(host) - let history = CommandHistoryModel( - entries: (0.. Void, insert: @escaping (CommandHistoryEntry) -> Void, - saveAsSnippet: @escaping (CommandHistoryEntry) -> Void, - remove: @escaping (CommandHistoryEntry) -> Void, - clear: @escaping () -> Void + saveAsSnippet: @escaping (CommandHistoryEntry) -> Void ) -> [NativeMenuEntry] { - var entries = [ + let entries = [ NativeMenuEntry( title: localized(AppStrings.Common.copy, locale: locale), symbol: MenuSymbol.copy, @@ -515,25 +513,6 @@ enum TerminalSidebarMenuBuilder { if let entry { saveAsSnippet(entry) } }, ] - if let entry { - entries.append( - NativeMenuEntry( - title: localized(AppStrings.Common.delete, locale: locale), - symbol: MenuSymbol.delete, - imageVisibility: .visible - ) { - remove(entry) - }) - } - entries.append(.separator) - entries.append( - NativeMenuEntry( - title: localized(AppStrings.Common.clearAll, locale: locale), - symbol: MenuSymbol.clearHistory, - imageVisibility: .visible, - isEnabled: canClear, - action: clear - )) return entries } @@ -815,7 +794,6 @@ private struct SSHCommandHistorySidebar: View { @State private var query = "" @State private var destructiveEntry: CommandHistoryEntry? - @State private var confirmsClear = false @State private var namingEntryID: CommandHistoryEntry.ID? @State private var snippetName = "" @State private var selectedID: CommandHistoryEntry.ID? @@ -824,25 +802,10 @@ private struct SSHCommandHistorySidebar: View { window.selectedWorkspace?.focusedPane.sshSession } - private var isSavedHost: Bool { - guard let hostID = sshSession?.hostID else { return false } - return app.hostLibrary.host(hostID) != nil - } - private var allEntries: [CommandHistoryEntry] { guard let sshSession else { return [] } - _ = app.commandHistory.revision - if isSavedHost { - return DesktopHistoryPresentation.limited( - app.commandHistory.entries(for: sshSession.hostID, matching: query) - ) - } - let entries = sshSession.transientCommandHistory - let matches = - query.isEmpty - ? entries - : entries.filter { $0.command.localizedCaseInsensitiveContains(query) } - return DesktopHistoryPresentation.limited(matches) + _ = sshSession.commandHistory.revision + return DesktopHistoryPresentation.limited(sshSession.commandHistory.entries(matching: query)) } var body: some View { @@ -895,16 +858,6 @@ private struct SSHCommandHistorySidebar: View { SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory)) } - .confirmationDialog( - AppStrings.Terminal.clearCommandHistoryForThisHostConfirmation, - isPresented: $confirmsClear, - titleVisibility: .visible - ) { - Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive, action: clearHistory) - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} - } message: { - Text(AppStrings.Terminal.clearCommandHistoryWarning) - } .confirmationDialog( AppStrings.Assistant.executeADestructiveCommandConfirmation, isPresented: Binding( @@ -922,9 +875,7 @@ private struct SSHCommandHistorySidebar: View { } } - /// The blank area offers the same menu a row does, with everything that - /// needs a row greyed out. Clear All is not one of those — it is about - /// the list, so it stays live where there is nothing under the pointer. + /// The blank area offers the row menu with entry-specific actions disabled. private var globalMenu: [NativeMenuEntry] { historyMenu(for: nil) } @@ -957,8 +908,7 @@ private struct SSHCommandHistorySidebar: View { } else { CommandHistoryCard( entry: entry, - isSelected: selectedID == entry.id, - delete: { delete(entry) } + isSelected: selectedID == entry.id ) .onTapGesture(count: 2) { selectedID = entry.id @@ -978,13 +928,10 @@ private struct SSHCommandHistorySidebar: View { TerminalSidebarMenuBuilder.history( entry: entry, canInsert: canInsert, - canClear: !allEntries.isEmpty, locale: locale, copy: { copyToPasteboard($0.command) }, insert: insert, - saveAsSnippet: beginSnippetNaming, - remove: delete, - clear: { confirmsClear = true } + saveAsSnippet: beginSnippetNaming ) } @@ -1037,23 +984,6 @@ private struct SSHCommandHistorySidebar: View { destructiveEntry = nil } - private func delete(_ entry: CommandHistoryEntry) { - if isSavedHost { - app.commandHistory.delete(entry.id) - } else { - sshSession?.deleteTransientHistory(entry.id) - } - } - - private func clearHistory() { - guard let sshSession else { return } - if isSavedHost { - app.commandHistory.clear(for: sshSession.hostID) - } else { - sshSession.clearTransientHistory() - } - } - private func beginSnippetNaming(_ entry: CommandHistoryEntry) { snippetName = "" namingEntryID = entry.id @@ -1082,7 +1012,6 @@ private struct SFTPBrowseHistorySidebar: View { @State private var query = "" @State private var selectedID: SFTPHistoryEntry.ID? - @State private var confirmsClear = false private var session: SFTPChannelModel? { window.selectedWorkspace?.focusedPane.sftpChannel @@ -1090,8 +1019,8 @@ private struct SFTPBrowseHistorySidebar: View { private var entries: [SFTPHistoryEntry] { guard let session else { return [] } - _ = app.sftpHistory.revision - let values = app.sftpHistory.entries(for: session.hostID) + _ = session.history.revision + let values = session.history.entries() let matches = query.isEmpty ? values @@ -1135,37 +1064,9 @@ private struct SFTPBrowseHistorySidebar: View { SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory)) } - .confirmationDialog( - AppStrings.SFTP.clearFileHistoryForThisHostConfirmation, isPresented: $confirmsClear, - titleVisibility: .visible - ) { - Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) { - if let session { app.sftpHistory.clear(for: session.hostID) } - } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} - } } - private var globalMenu: [NativeMenuEntry] { - [ - NativeMenuEntry( - title: AppStrings.Common.open, symbol: MenuSymbol.open, isEnabled: false - ), - NativeMenuEntry( - title: AppStrings.Common.delete, - symbol: MenuSymbol.delete, - imageVisibility: .visible, - isEnabled: false - ), - .separator, - NativeMenuEntry( - title: AppStrings.Common.clearAll, - symbol: MenuSymbol.clearHistory, - imageVisibility: .visible, - isEnabled: !entries.isEmpty, - ) { confirmsClear = true }, - ] - } + private var globalMenu: [NativeMenuEntry] { [] } private func row(_ entry: SFTPHistoryEntry, displayedPath: String) -> some View { SidebarCard( @@ -1183,18 +1084,7 @@ private struct SFTPBrowseHistorySidebar: View { [ NativeMenuEntry(title: AppStrings.Common.open, symbol: MenuSymbol.open) { reopen(entry) - }, - NativeMenuEntry( - title: AppStrings.Common.delete, - symbol: MenuSymbol.delete, - imageVisibility: .visible, - ) { app.sftpHistory.delete(entry.id) }, - .separator, - NativeMenuEntry( - title: AppStrings.Common.clearAll, - symbol: MenuSymbol.clearHistory, - imageVisibility: .visible, - ) { confirmsClear = true }, + } ] } } @@ -1206,8 +1096,9 @@ private struct SFTPBrowseHistorySidebar: View { session.navigate(to: entry.path) case .file: session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers, edits: app.sftpEdits) { + [history = session.history] in if NSWorkspace.shared.open($0) { - session.recordOpenedFile(at: entry.path) + history.record(kind: .file, path: entry.path) } } } @@ -1400,45 +1291,24 @@ extension View { private struct CommandHistoryCard: View { let entry: CommandHistoryEntry let isSelected: Bool - let delete: () -> Void - @Environment(\.locale) private var locale - @State private var isHovering = false var body: some View { + let subtitle = CommandHistoryPresentation.subtitle(for: entry, locale: locale) SidebarCard( symbol: nil, title: CommandHistoryPresentation.title(for: entry), titleIsMonospaced: true, titleLineLimit: 1, - subtitle: CommandHistoryPresentation.subtitle(for: entry, locale: locale), + subtitle: subtitle.isEmpty ? nil : subtitle, subtitleIsMonospaced: false, isSelected: isSelected ) { - ZStack { - if isHovering { - Button(action: delete) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - .frame(width: 18, height: 18) - .contentShape(.rect) - } - .buttonStyle(.plain) - .help(AppStrings.Common.delete.swiftUIKey) - .accessibilityLabel(AppStrings.Common.delete.swiftUIKey) - } else { - RiskBadge( - assessment: CommandRiskClassifier.assess(entry.command), - compact: true - ) - } - } - .frame(width: 18, height: 18, alignment: .center) + RiskBadge(assessment: CommandRiskClassifier.assess(entry.command), compact: true) + .frame(width: 18, height: 18, alignment: .center) } - .onHover { isHovering = $0 } .help(Text(entry.command)) } - } /// A native borderless field whose editor remains transparent inside a Sidebar diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index b46e039e..86778456 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -30,7 +30,8 @@ protocol SSHSessionInputRouting: AnyObject { /// terminal backend to one SSH session and exposes connection state to SwiftUI. @Observable final class SSHSessionModel: Identifiable { - let id = UUID() + let commandHistory = CommandHistoryModel() + var id: UUID { commandHistory.sessionID } /// Never optional. Termind ships no local terminal — the App Sandbox has no /// route to a login shell worth having, and shipping a crippled one would be /// worse than shipping none — so a sshSession is always a sshSession *on* a host. @@ -66,10 +67,6 @@ final class SSHSessionModel: Identifiable { let commandMonitor = TerminalCommandMonitor() private var commandObservation: TerminalCommandObservation? var hasUnreadOutput: Bool = false - /// History for an unsaved Quick Connect destination. Saved Hosts use the - /// app-wide CommandHistoryModel instead so every window sees the same list. - private(set) var transientCommandHistory: [CommandHistoryEntry] = [] - /// Find stays with this terminal's scrollback rather than with the transient /// SwiftUI pane mounting it, so moving a terminal between panes carries its /// open search and retained query along with the buffer they search. Closing @@ -138,14 +135,16 @@ final class SSHSessionModel: Identifiable { /// rather than the transient SwiftUI branch that currently displays it. var terminalViewOwner: PersistentTerminalViewOwner { terminalSurface } private weak var statusStore: (any RemoteSessionHostStore)? - weak var commandHistoryStore: CommandHistoryModel? private var ssh: SSHPTYChannel? var connectionCoordinator: RemoteConnectionCoordinator? private var connectionTask: Task? private var startsOnMount = true private(set) var connectionAttemptID: UUID? { didSet { - if connectionAttemptID != oldValue { terminalUpload.cancelPendingUpload() } + if connectionAttemptID != oldValue { + terminalUpload.cancelPendingUpload() + commandHistory.cancelLoading() + } } } @ObservationIgnored lazy var terminalUpload = TerminalUploadModel(session: self) @@ -774,6 +773,7 @@ final class SSHSessionModel: Identifiable { } func disconnect() { + commandHistory.cancelLoading() terminalUpload.cancelPendingUpload() commandMonitor.endConnection() startsOnMount = false @@ -886,6 +886,7 @@ final class SSHSessionModel: Identifiable { temporaryHostKeys.removeAll() _ = connection.markConnected() status = .connected + commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil) if presentsConnectionJourney { connectionJourney.succeed() } else { @@ -1084,7 +1085,6 @@ final class SSHSessionModel: Identifiable { copy.connectionRoute = connectionRoute copy.connectionOrigin = connectionOrigin copy.connectionCoordinator = connectionCoordinator - copy.commandHistoryStore = commandHistoryStore return copy } @@ -1233,18 +1233,7 @@ final class SSHSessionModel: Identifiable { commandExcludedFromHistory = nil return nil } - if statusStore?.host(hostID) != nil { - return commandHistoryStore?.record(command, for: hostID)?.id - } else { - let entry = CommandHistoryEntry(hostID: hostID, command: command) - transientCommandHistory.insert(entry, at: 0) - if transientCommandHistory.count > CommandHistoryModel.perHostLimit { - transientCommandHistory.removeLast( - transientCommandHistory.count - CommandHistoryModel.perHostLimit - ) - } - return entry.id - } + return commandHistory.record(command)?.id } private func recordCommandCompletion( @@ -1253,26 +1242,9 @@ final class SSHSessionModel: Identifiable { guard let active = activeShellCommand else { return } activeShellCommand = nil guard let historyID = active.historyID else { return } - if statusStore?.host(hostID) != nil { - commandHistoryStore?.recordCompletion( - for: historyID, hostID: hostID, - exitStatus: exitStatus, duration: reportedDuration - ) - } else if let index = transientCommandHistory.firstIndex(where: { - $0.id == historyID - }) { - transientCommandHistory[index].exitStatus = exitStatus - transientCommandHistory[index].duration = reportedDuration - } + commandHistory.recordCompletion(for: historyID, exitStatus: exitStatus, duration: reportedDuration) } - func deleteTransientHistory(_ id: CommandHistoryEntry.ID) { - transientCommandHistory.removeAll { $0.id == id } - } - - func clearTransientHistory() { - transientCommandHistory.removeAll() - } } private final class SSHSessionRelay: @unchecked Sendable { diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index 49bdc704..fcf1256c 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -125,16 +125,6 @@ final class Pane: Identifiable { } } - func configureCommandHistory(using store: CommandHistoryModel) { - guard case .sshSession(let session) = content else { return } - session.commandHistoryStore = store - } - - func configureSFTPHistory(using store: SFTPHistoryModel) { - guard case .sftpChannel(let session) = content else { return } - session.historyStore = store - } - func configureConnectionHistory( entryID: ConnectionHistoryEntry.ID, end: @escaping (ConnectionHistoryEntry.ID) -> Void @@ -207,9 +197,13 @@ final class Pane: Identifiable { endConnectionHistory = nil } switch content { - case .sshSession(let sshSession): sshSession.disconnect() + case .sshSession(let sshSession): + sshSession.commandHistory.close() + sshSession.disconnect() case .telnetSession(let telnetSession): telnetSession.disconnect() - case .sftpChannel(let sftpChannel): sftpChannel.disconnect() + case .sftpChannel(let sftpChannel): + sftpChannel.history.close() + sftpChannel.disconnect() case .serialSession(let serialSession): serialSession.disconnect() default: break } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 14508a39..63c2c614 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -113,18 +113,6 @@ final class WindowModel { } } } - weak var commandHistoryStore: CommandHistoryModel? { - didSet { - guard let commandHistoryStore else { return } - for workspace in workspaces { workspace.configureCommandHistory(using: commandHistoryStore) } - } - } - weak var sftpHistoryStore: SFTPHistoryModel? { - didSet { - guard let sftpHistoryStore else { return } - for workspace in workspaces { workspace.configureSFTPHistory(using: sftpHistoryStore) } - } - } var assistantCommands: AssistantConversationCommands? var assistantOperationError: String? var workspaceTemplateNotice: String? @@ -865,12 +853,6 @@ final class WindowModel { if let connectionCoordinator { workspace.configureConnections(using: connectionCoordinator) } - if let commandHistoryStore { - workspace.configureCommandHistory(using: commandHistoryStore) - } - if let sftpHistoryStore { - workspace.configureSFTPHistory(using: sftpHistoryStore) - } workspaces.append(workspace) if shouldSelect { select(workspace.id) } return workspace @@ -1024,7 +1006,6 @@ final class WindowModel { let pane = Pane.sftpChannel(host: host, initialPath: initialPath) configureConnectionHistory(pane) if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) } - if let sftpHistoryStore { pane.configureSFTPHistory(using: sftpHistoryStore) } if placement == .currentWorkspace, let workspace = selectedWorkspace, diff --git a/TFM/Features/Workspaces/Workspace.swift b/TFM/Features/Workspaces/Workspace.swift index 130f1a00..e19ffbd5 100644 --- a/TFM/Features/Workspaces/Workspace.swift +++ b/TFM/Features/Workspaces/Workspace.swift @@ -455,14 +455,6 @@ extension Workspace { for pane in panes { pane.configureConnections(using: coordinator) } } - func configureCommandHistory(using store: CommandHistoryModel) { - for pane in panes { pane.configureCommandHistory(using: store) } - } - - func configureSFTPHistory(using store: SFTPHistoryModel) { - for pane in panes { pane.configureSFTPHistory(using: store) } - } - /// A host is required: there is no local terminal to open without one. static func sshSession(host: Host, usernameHint: String? = nil) -> Workspace { Workspace(pane: .sshSession(host: host, usernameHint: usernameHint)) diff --git a/TFM/Infrastructure/Database/CommandHistoryModel+SwiftData.swift b/TFM/Infrastructure/Database/CommandHistoryModel+SwiftData.swift deleted file mode 100644 index 74363c5b..00000000 --- a/TFM/Infrastructure/Database/CommandHistoryModel+SwiftData.swift +++ /dev/null @@ -1,9 +0,0 @@ -import SwiftData -import TermindApplication -import TermindPersistence - -extension CommandHistoryModel { - convenience init(modelContext: ModelContext) { - self.init(repository: SwiftDataCommandHistoryRepository(context: modelContext)) - } -} diff --git a/TFMTests/CommandHistoryTests.swift b/TFMTests/CommandHistoryTests.swift index 63f8c8ff..bd50a382 100644 --- a/TFMTests/CommandHistoryTests.swift +++ b/TFMTests/CommandHistoryTests.swift @@ -19,18 +19,18 @@ struct CommandHistoryTests { func historyNormalizesExplicitContinuationsAndPreservesRealLineBreaks() throws { let hostID = UUID() let legacy = CommandHistoryEntry( - hostID: hostID, + sessionID: hostID, command: "printf legacy \\\r\n\t value\nprintf retained" ) - let store = CommandHistoryModel(entries: [legacy]) + let store = CommandHistoryModel(sessionID: hostID, entries: [legacy]) let newCommand = "printf new \\\n value \\\n again\nprintf retained" - let recorded = try #require(store.record(newCommand, for: hostID)) + let recorded = try #require(store.record(newCommand)) #expect(recorded.command == "printf new value again\nprintf retained") #expect( - store.entries(for: hostID).map(\.command) == [ + store.entries().map(\.command) == [ "printf new value again\nprintf retained", "printf legacy value\nprintf retained", ] @@ -44,12 +44,12 @@ struct CommandHistoryTests { @Test func historyTitleShowsOneLogicalLineWithoutChangingTheCommand() { - let single = CommandHistoryEntry(hostID: UUID(), command: "uptime") + let single = CommandHistoryEntry(sessionID: UUID(), command: "uptime") let multiline = CommandHistoryEntry( - hostID: UUID(), command: "printf first\nprintf second" + sessionID: UUID(), command: "printf first\nprintf second" ) let windowsMultiline = CommandHistoryEntry( - hostID: UUID(), command: "printf first\r\nprintf second" + sessionID: UUID(), command: "printf first\r\nprintf second" ) #expect(CommandHistoryPresentation.title(for: single) == "uptime") @@ -58,118 +58,31 @@ struct CommandHistoryTests { #expect(multiline.command == "printf first\nprintf second") } - @Test - func memoryStoreKeepsDuplicatesSearchesAndScopesByHost() throws { - let firstHost = UUID() - let secondHost = UUID() - let store = CommandHistoryModel() - - store.record("uptime", for: firstHost, at: Date(timeIntervalSince1970: 1)) - let duplicate = try #require( - store.record("uptime", for: firstHost, at: Date(timeIntervalSince1970: 2)) - ) - store.record("系统状态", for: firstHost, at: Date(timeIntervalSince1970: 3)) - store.record("other", for: secondHost, at: Date(timeIntervalSince1970: 4)) - - #expect(store.entries(for: firstHost).map(\.command) == ["系统状态", "uptime", "uptime"]) - #expect(store.entries(for: firstHost, matching: "TIME").count == 2) - #expect(store.entries(for: secondHost).map(\.command) == ["other"]) - - store.delete(duplicate.id) - #expect(store.entries(for: firstHost).map(\.command) == ["系统状态", "uptime"]) - store.clear(for: firstHost) - #expect(store.entries(for: firstHost).isEmpty) - #expect(store.entries(for: secondHost).count == 1) + @Test func memoryStoresKeepDuplicatesAndIsolateSessions() { + let first = CommandHistoryModel() + let second = CommandHistoryModel() + first.record("uptime") + first.record("uptime") + second.record("other") + #expect(first.entries(matching: "TIME").map(\.command) == ["uptime", "uptime"]) + #expect(second.entries().map(\.command) == ["other"]) + first.close() + #expect(first.entries().isEmpty) + #expect(second.entries().count == 1) } @Test func completionEnrichesExistingHistoryWithoutCreatingAnotherRow() throws { - let hostID = UUID() let store = CommandHistoryModel() - let entry = try #require(store.record("false", for: hostID)) + let entry = try #require(store.record("false")) let completed = store.recordCompletion( - for: entry.id, hostID: hostID, exitStatus: 1, duration: 0.25 + for: entry.id, exitStatus: 1, duration: 0.25 ) #expect(completed?.exitStatus == 1) #expect(completed?.duration == 0.25) - #expect(store.entries(for: hostID).count == 1) - } - - @Test - func swiftDataStorePersistsIncrementally() throws { - let container = try makeContainer("CommandHistoryPersistence") - let hostID = UUID() - let entryID: UUID - do { - let store = CommandHistoryModel(modelContext: container.mainContext) - entryID = try #require(store.record("echo 持久化", for: hostID)).id - store.recordCompletion( - for: entryID, hostID: hostID, exitStatus: 7, duration: 1.5 - ) - #expect(store.storageError == nil) - #expect( - try container.mainContext.fetch( - FetchDescriptor() - ).count == 1 - ) - } - - let reloaded = CommandHistoryModel(modelContext: container.mainContext) - #expect(reloaded.entries(for: hostID).map(\.id) == [entryID]) - #expect(reloaded.entries(for: hostID).map(\.command) == ["echo 持久化"]) - #expect(reloaded.entries(for: hostID).map(\.exitStatus) == [7]) - #expect(reloaded.entries(for: hostID).map(\.duration) == [1.5]) - #expect(reloaded.storageError == nil) - } - - @Test - func schemaV9HistoryMigratesWithUnknownCompletion() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("TermindCommandHistoryV10Migration-\(UUID().uuidString)") - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true - ) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("Termind.store") - let legacySchema = Schema(versionedSchema: TermindSchemaV9.self) - let value = CommandHistoryEntry( - hostID: UUID(), command: "uptime", - executedAt: Date(timeIntervalSince1970: 123) - ) - - var legacyContainer: ModelContainer? = try ModelContainer( - for: legacySchema, - configurations: [ - localModelConfiguration( - "CommandHistoryMigration", schema: legacySchema, url: storeURL - ) - ] - ) - legacyContainer?.mainContext.insert( - TermindSchemaV9Models.PersistentCommandHistoryEntry(value) - ) - try legacyContainer?.mainContext.save() - legacyContainer = nil - - let migrated = try ModelContainer( - for: HostPersistence.schema, - migrationPlan: TermindMigrationPlan.self, - configurations: [ - localModelConfiguration( - "CommandHistoryMigration", schema: HostPersistence.schema, - url: storeURL - ) - ] - ) - let entries = try migrated.mainContext.fetch( - FetchDescriptor() - ).map(\.value) - - #expect(entries.map(\.command) == ["uptime"]) - #expect(entries.first?.exitStatus == nil) - #expect(entries.first?.duration == nil) + #expect(store.entries().count == 1) } @Test @@ -187,10 +100,10 @@ struct CommandHistoryTests { session.recordExecutedCommand(command) #expect(String(decoding: submitted, as: UTF8.self) == "\(command)\r") - #expect(session.transientCommandHistory.isEmpty) + #expect(session.commandHistory.entries().isEmpty) session.recordExecutedCommand(command) - #expect(session.transientCommandHistory.map(\.command) == [command]) + #expect(session.commandHistory.entries().map(\.command) == [command]) } @Test @@ -206,7 +119,7 @@ struct CommandHistoryTests { session.submitPendingInitialCommand() session.recordExecutedCommand(reportedCommand) - #expect(session.transientCommandHistory.isEmpty) + #expect(session.commandHistory.entries().isEmpty) } @Test @@ -219,7 +132,7 @@ struct CommandHistoryTests { session.submitPendingInitialCommand() session.recordExecutedCommand("uptime") - #expect(session.transientCommandHistory.map(\.command) == ["uptime"]) + #expect(session.commandHistory.entries().map(\.command) == ["uptime"]) } @Test @@ -247,7 +160,7 @@ struct CommandHistoryTests { session.receiveShellIntegrationEvent(.commandStarted("false")) session.receiveShellIntegrationEvent(.commandFinished(1, duration: 5)) - let entry = try #require(session.transientCommandHistory.first) + let entry = try #require(session.commandHistory.entries().first) #expect(entry.command == "false") #expect(entry.exitStatus == 1) #expect(entry.duration == 5) @@ -261,10 +174,45 @@ struct CommandHistoryTests { .commandStarted("printf first \\\n second\nprintf retained") ) - let entry = try #require(session.transientCommandHistory.first) + let entry = try #require(session.commandHistory.entries().first) #expect(entry.command == "printf first second\nprintf retained") } + @Test func sessionsOnTheSameHostAreIndependentAndClosingPreventsLateCommands() { + let host = Host(name: "Shared", host: "example.invalid") + let first = SSHSessionModel(host: host) + let second = SSHSessionModel(host: host) + first.recordExecutedCommand("first") + second.recordExecutedCommand("second") + first.disconnect() + #expect(first.commandHistory.entries().map(\.command) == ["first"]) + let clone = first.copyForReconnect() + #expect(clone.commandHistory.entries().isEmpty) + let pane = Pane(.sshSession(first)) + pane.disconnectConnection() + first.recordExecutedCommand("late") + #expect(first.commandHistory.entries().isEmpty) + #expect(second.commandHistory.entries().map(\.command) == ["second"]) + pane.prepareForReopen() + #expect(pane.sshSession?.commandHistory.entries().isEmpty == true) + #expect(pane.sshSession?.id != first.id) + } + + @Test func reopenedSFTPPaneCannotReceiveOldTransferHistory() { + let channel = SFTPChannelModel(hostID: UUID()) + let pane = Pane(.sftpChannel(channel)) + let oldHistory = channel.history + oldHistory.record(kind: .file, path: "/before") + pane.disconnectConnection() + pane.prepareForReopen() + oldHistory.record(kind: .file, path: "/late") + #expect(oldHistory.entries().isEmpty) + #expect(channel.history.entries().isEmpty) + #expect(channel.history.sessionID != oldHistory.sessionID) + channel.recordOpenedFile(at: "/current") + #expect(channel.history.entries().map(\.path) == ["/current"]) + } + private func makeContainer(_ name: String) throws -> ModelContainer { let configuration = localModelConfiguration( name, schema: HostPersistence.schema, isStoredInMemoryOnly: true diff --git a/TFMTests/ConnectionHistoryTests.swift b/TFMTests/ConnectionHistoryTests.swift index 683028bb..de321133 100644 --- a/TFMTests/ConnectionHistoryTests.swift +++ b/TFMTests/ConnectionHistoryTests.swift @@ -19,8 +19,8 @@ struct ConnectionHistoryTests { @Test func sharedHistoryModelsUseTheSameRetentionLimit() { #expect(ConnectionHistoryModel.limit == HistoryLimits.retainedEntries) - #expect(CommandHistoryModel.perHostLimit == HistoryLimits.retainedEntries) - #expect(SFTPHistoryModel.perHostLimit == HistoryLimits.retainedEntries) + #expect(CommandHistoryModel.perSessionLimit == HistoryLimits.retainedEntries) + #expect(SFTPHistoryModel.perSessionLimit == HistoryLimits.retainedEntries) } @Test @@ -287,15 +287,12 @@ struct ConnectionHistoryTests { ConnectionHistoryEntry(hostID: other.id, kind: .ssh, sourceDeviceID: localID), ], sourceDeviceID: localID) - let commands = CommandHistoryModel(entries: [ - CommandHistoryEntry(hostID: host.id, command: "uptime") - ]) - let files = SFTPHistoryModel(entries: [ - SFTPHistoryEntry(hostID: host.id, kind: .directory, path: "/srv") - ]) + let session = SSHSessionModel(host: host) + session.commandHistory.record("uptime") + let files = SFTPHistoryModel() + files.record(kind: .directory, path: "/srv") let app = AppModel( hosts: HostLibraryModel(hosts: [host, other]), - commandHistory: commands, sftpHistory: files, connectionHistory: connections ) @@ -303,8 +300,8 @@ struct ConnectionHistoryTests { #expect(app.hostLibrary.host(host.id) != nil) #expect(connections.entries(for: host.id).isEmpty) - #expect(commands.entries(for: host.id).isEmpty) - #expect(files.entries(for: host.id).isEmpty) + #expect(session.commandHistory.entries().map(\.command) == ["uptime"]) + #expect(files.entries().map(\.path) == ["/srv"]) #expect(connections.entries(for: other.id).count == 1) #expect(connections.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [other.id]) } diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index 35dd78c4..a27caa1d 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -18,8 +18,7 @@ struct HostShellIntegrationSessionTests { var host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: mode) let library = HostLibraryModel(hosts: [host]) let session = SSHSessionModel(hostID: host.id, title: host.name) - let history = CommandHistoryModel() - session.commandHistoryStore = history + let history = session.commandHistory defer { session.disconnect() } // The missing identity holds connection setup at authentication without opening a socket. session.connect(using: library) @@ -41,7 +40,7 @@ struct HostShellIntegrationSessionTests { #expect(session.canGenerateFromCurrentDraft == expected) session.receiveShellIntegrationEvent(.commandStarted("pwd")) session.receiveShellIntegrationEvent(.commandFinished(0, duration: 0.1)) - #expect(history.entries(for: host.id).count == (expected ? 1 : 0)) + #expect(history.entries().count == (expected ? 1 : 0)) library.replacePersistedSnapshot(VaultSnapshot(hosts: [host])) #expect(session.isShellIntegrationEnabled == expected) diff --git a/TFMTests/ListViewMigrationTests.swift b/TFMTests/ListViewMigrationTests.swift index 31462ed1..e87418fb 100644 --- a/TFMTests/ListViewMigrationTests.swift +++ b/TFMTests/ListViewMigrationTests.swift @@ -133,7 +133,7 @@ struct ListViewMigrationTests { @Test func sidebarHistoryKeepsIdentityWhileInlineEditingChanges() { - let entry = CommandHistoryEntry(hostID: UUID(), command: "pwd") + let entry = CommandHistoryEntry(sessionID: UUID(), command: "pwd") let normal = SidebarListItem.history(entry, isNaming: false) let naming = SidebarListItem.history(entry, isNaming: true) @@ -152,9 +152,9 @@ struct ListViewMigrationTests { let hostID = UUID() let connection = ConnectionHistoryEntry(hostID: hostID, kind: .ssh) let items: [SidebarListItem] = [ - .history(CommandHistoryEntry(hostID: hostID, command: "pwd"), isNaming: false), + .history(CommandHistoryEntry(sessionID: hostID, command: "pwd"), isNaming: false), .sftpHistory( - SFTPHistoryEntry(hostID: hostID, kind: .directory, path: "/var/log") + SFTPHistoryEntry(sessionID: hostID, kind: .directory, path: "/var/log") ), .connection( connection, @@ -307,38 +307,35 @@ struct ListViewMigrationTests { #expect(disabledSnippetEntries.map(\.title) == ["Copy", "Insert"]) #expect(disabledSnippetEntries.allSatisfy { !$0.isEnabled }) - let history = CommandHistoryEntry(hostID: UUID(), command: "whoami") + let history = CommandHistoryEntry(sessionID: UUID(), command: "whoami") let historyEntries = TerminalSidebarMenuBuilder.history( - entry: history, canInsert: true, canClear: true, locale: locale, - copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }, - remove: { _ in }, clear: {} + entry: history, canInsert: true, locale: locale, + copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in } ) #expect( historyEntries.map(\.title) == [ - "Copy", "Insert", "Save as Snippet", "Delete", "", "Clear All", + "Copy", "Insert", "Save as Snippet", ] ) - #expect(historyEntries[4].isSeparator) + #expect(!historyEntries.contains { $0.isSeparator }) let blankHistoryEntries = TerminalSidebarMenuBuilder.history( - entry: nil, canInsert: false, canClear: true, locale: locale, - copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }, - remove: { _ in }, clear: {} + entry: nil, canInsert: false, locale: locale, + copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in } ) #expect( blankHistoryEntries.map(\.title) - == ["Copy", "Insert", "Save as Snippet", "", "Clear All"] + == ["Copy", "Insert", "Save as Snippet"] ) #expect(blankHistoryEntries.prefix(3).allSatisfy { !$0.isEnabled }) - #expect(blankHistoryEntries.last?.isEnabled == true) } } @Test func sftpHistoryDisplayPathParticipatesInRowContentUpdates() { let entry = SFTPHistoryEntry( - hostID: UUID(), kind: .directory, path: "/home/deploy/project" + sessionID: UUID(), kind: .directory, path: "/home/deploy/project" ) let absolute = SidebarListItem.sftpHistory(entry) diff --git a/TFMTests/RepositoryOutboxTests.swift b/TFMTests/RepositoryOutboxTests.swift index 86134094..356d11e6 100644 --- a/TFMTests/RepositoryOutboxTests.swift +++ b/TFMTests/RepositoryOutboxTests.swift @@ -192,15 +192,6 @@ struct RepositoryOutboxTests { } context.insert(PersistentSnippet(snippet)) context.insert(PersistentPortForwardingRule(rule)) - context.insert( - PersistentCommandHistoryEntry( - CommandHistoryEntry(hostID: doomed.id, command: "uptime") - ) - ) - context.insert( - PersistentSFTPHistoryEntry( - SFTPHistoryEntry(hostID: doomed.id, kind: .directory, path: "/srv") - )) let connectionHistory = ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh) context.insert(PersistentConnectionHistoryEntry(connectionHistory)) try context.save() @@ -236,8 +227,6 @@ struct RepositoryOutboxTests { == [dependent.id] ) #expect(try context.fetch(FetchDescriptor()).isEmpty) - #expect(try context.fetch(FetchDescriptor()).isEmpty) - #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) let connectionHistoryEnvelope = try #require( try HistorySyncJournal(context: context).envelope(