feat: remove persistence connection and SFTP history
This commit is contained in:
1 parent
5d5a3585ac
commit
aa5a92c352
54 files changed
+1790
-1811
No files matched your search
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<PersistentHostGroup>()).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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
public enum HistoryLimits {
|
||||
public nonisolated enum HistoryLimits {
|
||||
public static let retainedEntries = 250
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import TermindDomain
|
||||
|
||||
public nonisolated protocol ShellHistoryLoading: Sendable {
|
||||
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)..<semicolon].split(separator: ":")
|
||||
guard fields.count == 2, let date = timestamp(String(fields[0])),
|
||||
let duration = Double(fields[1]), duration.isFinite, duration >= 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistentCommandHistoryEntry>(
|
||||
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<PersistentCommandHistoryEntry>(
|
||||
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<PersistentCommandHistoryEntry>(
|
||||
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<PersistentCommandHistoryEntry>(
|
||||
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<PersistentCommandHistoryEntry>(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PersistentSFTPHistoryEntry>(
|
||||
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<PersistentSFTPHistoryEntry>(
|
||||
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<PersistentSFTPHistoryEntry>(
|
||||
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<PersistentSFTPHistoryEntry>(
|
||||
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<PersistentSFTPHistoryEntry>(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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..<result.stdout.endIndex)
|
||||
else { throw TermindSSHError.library("History response was incomplete.") }
|
||||
let fields = result.stdout[start.upperBound..<lineEnd].split(separator: ";", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2, let size = Int(fields[1]), size >= 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..<end.lowerBound]), options: .ignoreUnknownCharacters),
|
||||
bytes.count <= Self.maximumFileBytes
|
||||
else { throw TermindSSHError.library("History response was invalid.") }
|
||||
if shell == .zsh { bytes = Self.unmetafy(bytes) }
|
||||
let text = try RemoteCharsetStreamConverter.decode(bytes, charset: charset)
|
||||
return Array(
|
||||
ShellHistoryParser.parse(text, shell: shell, truncatedStart: size > 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
|
||||
}
|
||||
}
|
||||
@@ -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<CChar>
|
||||
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<SSHExecResult, any Error>
|
||||
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<SSHExecResult, any Error>
|
||||
) 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<Void>()
|
||||
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<UUID>()
|
||||
private var opening: SSHExecChannel?
|
||||
private var ready = SSHReadyQueue<UUID>()
|
||||
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<SSHExecResult, any Error>) 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<CChar>?
|
||||
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
|
||||
|
||||
@@ -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<UUID>()
|
||||
private var opening: SSHExecChannel?
|
||||
private var ready = SSHReadyQueue<UUID>()
|
||||
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<CChar>
|
||||
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<SSHExecResult, any Error>
|
||||
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<SSHExecResult, any Error>
|
||||
) 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<SSHExecResult, any Error>) 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<CChar>?
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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..<end])
|
||||
if payload.last == 0x0D { payload.removeLast() }
|
||||
let path = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset)
|
||||
guard !path.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
|
||||
return ShellHistorySource(shell: shell, path: path)
|
||||
}
|
||||
|
||||
private mutating func parseHomeDirectory(in data: Data) -> 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -167,7 +167,9 @@ final class MobileWorkspace: Identifiable {
|
||||
private var sshEventContinuation: AsyncStream<MobileSSHSessionEvent>.Continuation?
|
||||
private var sshEventTask: Task<Void, Never>?
|
||||
private var attempt: Task<Void, Never>?
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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..<CommandHistoryModel.perHostLimit).map { index in
|
||||
CommandHistoryEntry(
|
||||
hostID: host.id, command: "printf '%04d' \(index)",
|
||||
executedAt: Date(timeIntervalSince1970: TimeInterval(index)))
|
||||
})
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
|
||||
hosts: app.library,
|
||||
commandHistory: history)
|
||||
|
||||
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
|
||||
for index in 0..<CommandHistoryModel.perSessionLimit {
|
||||
workspace.commandHistory.record("printf '%04d' \(index)")
|
||||
}
|
||||
try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in
|
||||
#expect(history.entries(for: host.id).count == CommandHistoryModel.perHostLimit)
|
||||
#expect(workspace.commandHistory.entries().count == CommandHistoryModel.perSessionLimit)
|
||||
let cells = nativeVisibleCells(in: root.view)
|
||||
#expect(!cells.isEmpty)
|
||||
#expect(cells.count < 50)
|
||||
|
||||
+3
-16
@@ -61,8 +61,6 @@ final class AppModel {
|
||||
let snippets: SnippetLibraryModel
|
||||
let forwardings: ForwardingLibraryModel
|
||||
let transfers: TransferLibraryModel
|
||||
let commandHistory: CommandHistoryModel
|
||||
let sftpHistory: SFTPHistoryModel
|
||||
let connectionHistory: ConnectionHistoryModel
|
||||
let devices: DeviceDirectoryModel
|
||||
private let externalToolCalls: ToolAuditModel
|
||||
@@ -185,8 +183,6 @@ final class AppModel {
|
||||
sshSettings: SSHSettingsModel = SSHSettingsModel(),
|
||||
sftpSettings: SFTPSettingsModel = SFTPSettingsModel(),
|
||||
workspaceSettings: WorkspaceSettingsModel = WorkspaceSettingsModel(),
|
||||
commandHistory: CommandHistoryModel = CommandHistoryModel(),
|
||||
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
|
||||
connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(),
|
||||
devices: DeviceDirectoryModel? = nil,
|
||||
tailscaleStore: (any TailscaleLocalStoring)? = nil,
|
||||
@@ -232,8 +228,6 @@ final class AppModel {
|
||||
snippets = SnippetLibraryModel(library: hosts)
|
||||
forwardings = ForwardingLibraryModel(library: hosts)
|
||||
transfers = TransferLibraryModel(library: hosts)
|
||||
self.commandHistory = commandHistory
|
||||
self.sftpHistory = sftpHistory
|
||||
self.connectionHistory = connectionHistory
|
||||
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
|
||||
self.devices = devices
|
||||
@@ -514,8 +508,6 @@ final class AppModel {
|
||||
sftpTransfers.cancelAll(for: change.id)
|
||||
externalAuthentication.cancel(hostID: change.id)
|
||||
mcpServer.tools.revokeHost(change.id)
|
||||
commandHistory.clear(for: change.id)
|
||||
sftpHistory.clear(for: change.id)
|
||||
connectionHistory.clear(for: change.id)
|
||||
}
|
||||
}
|
||||
@@ -723,8 +715,6 @@ final class AppModel {
|
||||
window.metricsModel = serverMetrics
|
||||
windows = windows.filter { $0.value.value != nil }
|
||||
windows[ObjectIdentifier(window)] = WeakWindow(window)
|
||||
window.commandHistoryStore = commandHistory
|
||||
window.sftpHistoryStore = sftpHistory
|
||||
window.assistantLibrary.adopt(assistantConversations)
|
||||
window.assistantLibrary.modelSelections = assistantModelSelections
|
||||
window.assistantCommands = AssistantConversationCommands(
|
||||
@@ -897,8 +887,6 @@ final class AppModel {
|
||||
serverMetrics.remove(hostID)
|
||||
forwarding.hostConfigurationChanged(hostID)
|
||||
forwarding.remove(hosts.portForwardings(for: hostID).map(\.id))
|
||||
commandHistory.clear(for: hostID)
|
||||
sftpHistory.clear(for: hostID)
|
||||
connectionHistory.clear(for: hostID)
|
||||
hosts.projectHostDeletion(hostID)
|
||||
return true
|
||||
@@ -1137,9 +1125,10 @@ final class AppModel {
|
||||
localized: AppStrings.SFTP.executableFileWarning)
|
||||
return
|
||||
}
|
||||
channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) { localURL in
|
||||
channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) {
|
||||
[history = channel.history] localURL in
|
||||
if NSWorkspace.shared.open(localURL) {
|
||||
channel.recordOpenedFile(at: file.path)
|
||||
history.record(kind: .file, path: file.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1174,8 +1163,6 @@ final class AppModel {
|
||||
func clearAllHistory(for hostIDs: [Host.ID]) {
|
||||
for hostID in Set(hostIDs) {
|
||||
connectionHistory.clear(for: hostID)
|
||||
commandHistory.clear(for: hostID)
|
||||
sftpHistory.clear(for: hostID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,10 +169,6 @@ struct TermindApp: App {
|
||||
sshSettings: SSHSettingsModel(persistence: settingsPersistence),
|
||||
sftpSettings: SFTPSettingsModel(persistence: settingsPersistence),
|
||||
workspaceSettings: WorkspaceSettingsModel(persistence: settingsPersistence),
|
||||
commandHistory: CommandHistoryModel(modelContext: container.mainContext),
|
||||
sftpHistory: SFTPHistoryModel(
|
||||
repository: SwiftDataSFTPHistoryRepository(context: container.mainContext)
|
||||
),
|
||||
connectionHistory: ConnectionHistoryModel(
|
||||
repository: connectionHistoryRepository,
|
||||
sourceDeviceID: connectionHistoryDeviceID
|
||||
|
||||
@@ -1018,20 +1018,22 @@ struct SFTPBrowserView: View {
|
||||
if file.isDirectory {
|
||||
sftpChannel.navigate(to: file.path)
|
||||
} else {
|
||||
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in
|
||||
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
|
||||
[history = sftpChannel.history] localURL in
|
||||
if NSWorkspace.shared.open(localURL) {
|
||||
sftpChannel.recordOpenedFile(at: file.path)
|
||||
history.record(kind: .file, path: file.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ file: RemoteFile, with application: URL) {
|
||||
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in
|
||||
sftpChannel.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
|
||||
[history = sftpChannel.history] localURL in
|
||||
Task {
|
||||
do {
|
||||
try await SFTPApplicationOpener.open(localURL, with: application)
|
||||
sftpChannel.recordOpenedFile(at: file.path)
|
||||
history.record(kind: .file, path: file.path)
|
||||
} catch { sftpChannel.operationError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ private struct SFTPDirectoryPresentation {
|
||||
|
||||
@Observable
|
||||
final class SFTPChannelModel: Identifiable {
|
||||
let id = UUID()
|
||||
var history = SFTPHistoryModel()
|
||||
var id: UUID { history.sessionID }
|
||||
let hostID: Host.ID
|
||||
let initialPath: String
|
||||
var homeResolution: RemoteHomeResolutionState = .pending
|
||||
@@ -169,7 +170,6 @@ final class SFTPChannelModel: Identifiable {
|
||||
weak var navigationRouter: (any SFTPChannelNavigationRouting)?
|
||||
private weak var statusStore: (any RemoteSessionHostStore)?
|
||||
private weak var transferCoordinator: SFTPTransferCoordinator?
|
||||
weak var historyStore: SFTPHistoryModel?
|
||||
private var didResolveHomeForConnection = false
|
||||
private let automaticRefreshInterval: Duration
|
||||
var connectionCoordinator: RemoteConnectionCoordinator?
|
||||
@@ -304,6 +304,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
}
|
||||
|
||||
func prepareForReopen() {
|
||||
history = SFTPHistoryModel()
|
||||
startsOnMount = true
|
||||
}
|
||||
|
||||
@@ -847,6 +848,8 @@ final class SFTPChannelModel: Identifiable {
|
||||
func reload(silently: Bool = false) async {
|
||||
guard let client, status == .connected else { return }
|
||||
let requestedPath = path
|
||||
let requestedHistory = history
|
||||
let requestedAttempt = connectionAttemptID
|
||||
if !silently {
|
||||
isLoading = true
|
||||
hint = String(localized: AppStrings.SFTP.loading)
|
||||
@@ -854,10 +857,12 @@ final class SFTPChannelModel: Identifiable {
|
||||
defer { if !silently { isLoading = false } }
|
||||
do {
|
||||
let snapshot = try await client.listDirectory(at: requestedPath)
|
||||
guard status == .connected, path == requestedPath else { return }
|
||||
guard status == .connected, path == requestedPath, connectionAttemptID == requestedAttempt,
|
||||
history === requestedHistory
|
||||
else { return }
|
||||
replaceDirectoryContents(with: snapshot)
|
||||
if !silently {
|
||||
historyStore?.record(kind: .directory, path: requestedPath, for: hostID)
|
||||
history.record(kind: .directory, path: requestedPath)
|
||||
}
|
||||
hint = String(localized: AppStrings.SFTP.items(snapshot.count))
|
||||
} catch let error as SFTPError {
|
||||
@@ -1067,9 +1072,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
}
|
||||
|
||||
func recordOpenedFile(at path: String) {
|
||||
if connectionOrigin.allowsPersistence {
|
||||
historyStore?.record(kind: .file, path: path, for: hostID)
|
||||
}
|
||||
history.record(kind: .file, path: path)
|
||||
}
|
||||
|
||||
func reopenHistoryFile(
|
||||
|
||||
@@ -485,14 +485,12 @@ enum TerminalSidebarMenuBuilder {
|
||||
}
|
||||
|
||||
static func history(
|
||||
entry: CommandHistoryEntry?, canInsert: Bool, canClear: Bool, locale: Locale,
|
||||
entry: CommandHistoryEntry?, canInsert: Bool, locale: Locale,
|
||||
copy: @escaping (CommandHistoryEntry) -> 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
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import SwiftData
|
||||
import TermindApplication
|
||||
import TermindPersistence
|
||||
|
||||
extension CommandHistoryModel {
|
||||
convenience init(modelContext: ModelContext) {
|
||||
self.init(repository: SwiftDataCommandHistoryRepository(context: modelContext))
|
||||
}
|
||||
}
|
||||
@@ -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<PersistentCommandHistoryEntry>()
|
||||
).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<PersistentCommandHistoryEntry>()
|
||||
).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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PersistentPortForwardingRule>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentCommandHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
|
||||
let connectionHistoryEnvelope = try #require(
|
||||
try HistorySyncJournal(context: context).envelope(
|
||||
|
||||
Reference in new issue
Block a user