diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 3c4ff34e..6516122b 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -54,6 +54,7 @@ final class AppModel { let forwardings: ForwardingLibraryModel let transfers: TransferLibraryModel let commandHistory: CommandHistoryModel + let sftpHistory: SFTPHistoryModel private let externalToolCalls: ToolAuditModel var toolAudit: ToolAuditModel { externalToolCalls } let externalAuthentication: ExternalAuthCoordinator @@ -100,6 +101,7 @@ final class AppModel { init( hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(), commandHistory: CommandHistoryModel = CommandHistoryModel(), + sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), externalToolCalls: ToolAuditModel = ToolAuditModel(), dependencies: AppDependencies? = nil, speechTranscription: SpeechTranscriptionCoordinator? = nil @@ -112,6 +114,7 @@ final class AppModel { forwardings = ForwardingLibraryModel(library: hosts) transfers = TransferLibraryModel(library: hosts) self.commandHistory = commandHistory + self.sftpHistory = sftpHistory self.externalToolCalls = externalToolCalls externalAuthentication = ExternalAuthCoordinator() connections = connectionCoordinator @@ -611,6 +614,7 @@ final class AppModel { windows = windows.filter { $0.value.value != nil } windows[ObjectIdentifier(window)] = WeakWindow(window) window.commandHistoryStore = commandHistory + window.sftpHistoryStore = sftpHistory window.assistantLibrary.adopt(assistantConversations) window.assistantConversationDeletionHandler = { [weak self] id in self?.deleteAgentConversation(id) @@ -824,6 +828,7 @@ final class AppModel { do { try hostRepository.requestHostDeletion(hostID) commandHistory.clear(for: hostID) + sftpHistory.clear(for: hostID) hosts.projectHostDeletion(hostID) } catch { hosts.reportStorageError(error) @@ -882,7 +887,11 @@ final class AppModel { /// know which workspace was first. Discardable because nearly every caller opens /// one host and has nothing to decide afterwards. @discardableResult - func open(_ host: Host, as target: HostOpenTarget, in window: WindowModel) -> Workspace { + func open( + _ host: Host, + as target: HostOpenTarget, + in window: WindowModel + ) -> Workspace { beginOpening(host, as: target) switch target { diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index a889f3e8..b924e464 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -64,6 +64,7 @@ struct TermindApp: App { modelContext: container.mainContext, secrets: secrets ), commandHistory: CommandHistoryModel(modelContext: container.mainContext), + sftpHistory: SFTPHistoryModel(modelContext: container.mainContext), externalToolCalls: externalToolCalls, dependencies: AppDependencies( hostRepository: repository, diff --git a/Termind/Application/FeatureModels/SFTPHistoryModel.swift b/Termind/Application/FeatureModels/SFTPHistoryModel.swift new file mode 100644 index 00000000..28bca3a3 --- /dev/null +++ b/Termind/Application/FeatureModels/SFTPHistoryModel.swift @@ -0,0 +1,58 @@ +import Foundation + +@MainActor +@Observable +final class SFTPHistoryModel { + static let perHostLimit = 1_000 + + private let repository: any SFTPHistoryRepository + private(set) var revision: UInt64 = 0 + private(set) var storageError: String? + + init( + entries: [SFTPHistoryEntry] = [], + repository: (any SFTPHistoryRepository)? = nil + ) { + self.repository = repository ?? InMemorySFTPHistoryRepository(entries: entries) + } + + 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) } + } + + func record( + kind: SFTPHistoryEntry.Kind, path: String, for hostID: Host.ID, + at date: Date = Date() + ) { + 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 + } + } + + func delete(_ id: SFTPHistoryEntry.ID) { + do { try repository.delete(id); storageError = nil; revision &+= 1 } + catch { storageError = error.localizedDescription } + } + + func clear(for hostID: Host.ID) { + do { try repository.clear(for: hostID); storageError = nil; revision &+= 1 } + catch { storageError = error.localizedDescription } + } +} diff --git a/Termind/Application/Repositories/SFTPHistoryRepository.swift b/Termind/Application/Repositories/SFTPHistoryRepository.swift new file mode 100644 index 00000000..1ee81b9f --- /dev/null +++ b/Termind/Application/Repositories/SFTPHistoryRepository.swift @@ -0,0 +1,32 @@ +import Foundation + +@MainActor +protocol SFTPHistoryRepository: AnyObject { + func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] + func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws + func delete(_ id: SFTPHistoryEntry.ID) throws + func clear(for hostID: Host.ID) throws +} + +@MainActor +final class InMemorySFTPHistoryRepository: SFTPHistoryRepository { + private var values: [SFTPHistoryEntry] + + init(entries: [SFTPHistoryEntry] = []) { values = entries } + + func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] { + values.filter { $0.hostID == hostID }.sorted { $0.visitedAt > $1.visitedAt } + } + + func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws { + values.removeAll { + $0.hostID == entry.hostID && $0.kind == entry.kind && $0.path == entry.path + } + values.append(entry) + let retained = Set(try entries(for: entry.hostID).prefix(limit).map(\.id)) + values.removeAll { $0.hostID == entry.hostID && !retained.contains($0.id) } + } + + func delete(_ id: SFTPHistoryEntry.ID) throws { values.removeAll { $0.id == id } } + func clear(for hostID: Host.ID) throws { values.removeAll { $0.hostID == hostID } } +} diff --git a/Termind/Domain/Transfers/SFTPHistoryEntry.swift b/Termind/Domain/Transfers/SFTPHistoryEntry.swift new file mode 100644 index 00000000..64298617 --- /dev/null +++ b/Termind/Domain/Transfers/SFTPHistoryEntry.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A remote path the user successfully visited or opened through SFTP. +nonisolated struct SFTPHistoryEntry: Identifiable, Hashable, Sendable { + nonisolated enum Kind: String, Hashable, Sendable { + case directory + case file + + var symbol: String { self == .directory ? "folder.fill" : "doc" } + } + + var id: UUID = UUID() + var hostID: Host.ID + var kind: Kind + var path: String + var visitedAt: Date = Date() + + var name: String { + if path == "/" { return "/" } + return (path as NSString).lastPathComponent + } +} diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index a9a5f6b5..b38b7361 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -66,7 +66,9 @@ struct AssistantPanelView: View { var body: some View { Group { - if window.sidebar.isAssistantArchivePresented { + if !isFocusedOnWorkspace { + AssistantSidebarView() + } else if window.sidebar.isAssistantArchivePresented { AssistantArchiveView( isCompact: true, onBack: { window.closeAssistantInSidebar() }, @@ -85,15 +87,19 @@ struct AssistantPanelView: View { ) } + private var isFocusedOnWorkspace: Bool { + if case .workspace = window.selectedDestination { true } else { false } + } + private var landing: some View { VStack(spacing: 0) { HStack { Text("Chats").font(.headline) Spacer(minLength: 0) Button { - window.showAssistantArchiveInSidebar() + openAssistantFullScreen() } label: { - Label("Archived", systemImage: "archivebox") + Label("Open Full Screen", systemImage: "arrow.up.left.and.arrow.down.right") } .buttonStyle(.borderless) .controlSize(.small) @@ -158,6 +164,13 @@ struct AssistantPanelView: View { .font(.headline) .lineLimit(1) Spacer(minLength: 0) + Button { + window.openAssistantFullScreen(conversation.id) + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + } + .buttonStyle(.borderless) + .help(Text("Open Full Screen")) } .compactAssistantHeader() ConversationView( @@ -168,6 +181,16 @@ struct AssistantPanelView: View { ) } } + + private func openAssistantFullScreen() { + if let id = window.sidebar.assistantConversationID + ?? window.assistantLibrary.selectedConversationID + { + window.openAssistantFullScreen(id) + } else { + window.openAssistantFullScreen() + } + } } private struct AssistantConversationList: View { @@ -259,7 +282,6 @@ private struct AssistantConversationList: View { items.append(contentsOf: window.assistantLibrary.pinnedConversations.map(item)) } if !window.assistantLibrary.activeConversations.isEmpty { - items.append(.section(.chats)) items.append(contentsOf: window.assistantLibrary.activeConversations.map(item)) } return items @@ -668,6 +690,7 @@ struct AssistantConversationRow: View { isArchived: conversation.isArchived, isPinned: conversation.isPinned, managementDisabled: conversation.isStreaming || conversation.isCompacting, + onOpenFullScreen: { window.openAssistantFullScreen(conversation.id) }, onTogglePin: { window.toggleAssistantPin(conversation.id) }, onRename: onBeginRename, onToggleArchive: { @@ -722,6 +745,7 @@ private struct ConversationContextMenuHost: NSViewRepresentable { let isArchived: Bool let isPinned: Bool let managementDisabled: Bool + let onOpenFullScreen: () -> Void let onTogglePin: () -> Void let onRename: () -> Void let onToggleArchive: () -> Void @@ -735,6 +759,7 @@ private struct ConversationContextMenuHost: NSViewRepresentable { view.isArchived = isArchived view.isPinned = isPinned view.managementDisabled = managementDisabled + view.onOpenFullScreen = onOpenFullScreen view.onTogglePin = onTogglePin view.onRename = onRename view.onToggleArchive = onToggleArchive @@ -745,6 +770,7 @@ private struct ConversationContextMenuHost: NSViewRepresentable { var isArchived = false var isPinned = false var managementDisabled = false + var onOpenFullScreen: () -> Void = {} var onTogglePin: () -> Void = {} var onRename: () -> Void = {} var onToggleArchive: () -> Void = {} @@ -763,6 +789,10 @@ private struct ConversationContextMenuHost: NSViewRepresentable { private func makeMenu() -> NSMenu { let menu = NSMenu() + menu.addItem(item( + String(localized: "Open Full Screen"), action: #selector(openFullScreen) + )) + menu.addItem(.separator()) if !isArchived { menu.addItem(item( isPinned ? String(localized: "Unpin") : String(localized: "Pin"), @@ -791,6 +821,7 @@ private struct ConversationContextMenuHost: NSViewRepresentable { } @objc private func togglePin() { onTogglePin() } + @objc private func openFullScreen() { onOpenFullScreen() } @objc private func rename() { onRename() } @objc private func toggleArchive() { onToggleArchive() } @objc private func deleteConversation() { onDelete() } diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/ContextPanel/ContextPanelView.swift index e1df9f38..ff60ff0e 100644 --- a/Termind/Features/ContextPanel/ContextPanelView.swift +++ b/Termind/Features/ContextPanel/ContextPanelView.swift @@ -71,7 +71,7 @@ struct SidebarView: View { case .snippets: SSHSessionSnippetsSidebar() case .history: - SSHSessionHistorySidebar() + AdaptiveHistorySidebar() } } } @@ -80,7 +80,6 @@ nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable { nonisolated enum SectionID: Hashable, Sendable { case package(UUID) case ungrouped - case recents case results } @@ -247,15 +246,15 @@ private final class ContextSidebarHostingRow: ListRowView { // MARK: - Session Snippets -/// The compact command shelf shown only while a terminal pane has focus. -/// Commands are sent straight to that pane, deliberately bypassing Workspace -/// broadcast and the snippet's saved Targets. +/// The global Snippet shelf. Execution always respects the Snippet's saved +/// Targets; the focused terminal is never an implicit destination. private struct SSHSessionSnippetsSidebar: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @State private var query = "" @State private var destructiveSnippet: Snippet? + @State private var selectedSnippetID: Snippet.ID? var body: some View { VStack(spacing: 0) { @@ -280,7 +279,7 @@ private struct SSHSessionSnippetsSidebar: View { } Divider() - Text("Click to insert · Double-click to execute") + Text("Double-click to execute on default Targets") .font(.caption2) .foregroundStyle(.secondary) .padding(.vertical, Metrics.Space.s) @@ -323,13 +322,12 @@ private struct SSHSessionSnippetsSidebar: View { SSHSessionSnippetRow(snippet: snippet) .padding(.horizontal, Metrics.Space.m) .contentShape(.rect) - .termindSidebarRowBackground(isSelected: false) - .onTapGesture(count: 2) { execute(snippet) } - .onTapGesture { insert(snippet) } + .termindSidebarRowBackground(isSelected: selectedSnippetID == snippet.id) + .onTapGesture(count: 2) { requestExecution(snippet) } + .onTapGesture { selectedSnippetID = snippet.id } .contextMenu { Button("Copy") { copyToPasteboard(snippet.body) } - Button("Insert") { insert(snippet) } - Button("Execute") { execute(snippet) } + Button("Execute") { requestExecution(snippet) } } } @@ -356,12 +354,13 @@ private struct SSHSessionSnippetsSidebar: View { } } - private func insert(_ snippet: Snippet) { - guard let tab = window.selectedWorkspace, let sshSession = tab.focusedPane.sshSession else { return } - tab.deliver(snippet.body, from: sshSession) - } - - private func execute(_ snippet: Snippet) { + private func requestExecution(_ snippet: Snippet) { + if snippet.targetHostIDs.isEmpty { + window.hostChooser = .snippet(SnippetHostChooserRequest( + snippetIDs: [snippet.id], strategy: .fillMissingTargets + )) + return + } if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation { destructiveSnippet = snippet } else { @@ -370,15 +369,28 @@ private struct SSHSessionSnippetsSidebar: View { } private func executeNow(_ snippet: Snippet) { - guard let tab = window.selectedWorkspace, let sshSession = tab.focusedPane.sshSession else { return } - tab.deliver(snippet.body, from: sshSession, submit: true) + app.execute([snippet], in: window) destructiveSnippet = nil } } -// MARK: - Session History +// MARK: - Focus-sensitive History -private struct SSHSessionHistorySidebar: View { +private struct AdaptiveHistorySidebar: View { + @Environment(WindowModel.self) private var window + + var body: some View { + if window.selectedWorkspace?.focusedPane.sshSession != nil { + SSHCommandHistorySidebar() + } else if window.selectedWorkspace?.focusedPane.sftpSession != nil { + SFTPBrowseHistorySidebar() + } else { + ConnectionHistorySidebar() + } + } +} + +private struct SSHCommandHistorySidebar: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -526,10 +538,11 @@ private struct SSHSessionHistorySidebar: View { } private var historyListItems: [ContextSidebarListItem] { - [.section(query.isEmpty ? .recents : .results, title: query.isEmpty ? "Recents" : "Results")] - + allEntries.map { - .history($0, isNaming: namingEntryID == $0.id) - } + let entries = allEntries.map { + ContextSidebarListItem.history($0, isNaming: namingEntryID == $0.id) + } + guard !query.isEmpty else { return entries } + return [.section(.results, title: "Results")] + entries } @ViewBuilder @@ -606,6 +619,165 @@ private struct SSHSessionHistorySidebar: View { } } +private struct SFTPBrowseHistorySidebar: View { + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + + @State private var query = "" + @State private var selectedID: SFTPHistoryEntry.ID? + @State private var confirmsClear = false + + private var session: SFTPSessionModel? { + window.selectedWorkspace?.focusedPane.sftpSession + } + + private var entries: [SFTPHistoryEntry] { + guard let session else { return [] } + _ = app.sftpHistory.revision + return app.sftpHistory.entries(for: session.hostID, matching: query) + } + + var body: some View { + VStack(spacing: 0) { + TermindSearchField(text: $query, prompt: "Search SFTP History", density: .compact) + .padding(.horizontal, Metrics.Space.m) + .padding(.vertical, Metrics.Space.s) + + if entries.isEmpty { + Group { + if query.isEmpty { + ContentUnavailableView( + "No SFTP History", systemImage: "clock.arrow.circlepath", + description: Text("Folders you visit and files you open appear here.") + ) + } else { + ContentUnavailableView.search(text: query) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(entries, selection: $selectedID) { entry in + HStack(spacing: Metrics.Space.s) { + Image(systemName: entry.kind.symbol) + .foregroundStyle( + entry.kind == .directory + ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary) + ) + VStack(alignment: .leading, spacing: 2) { + Text(entry.name).lineLimit(1) + Text(entry.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .frame(height: 42) + .contentShape(.rect) + .tag(entry.id) + .onTapGesture(count: 2) { reopen(entry) } + .contextMenu { + Button("Open") { reopen(entry) } + Divider() + Button("Delete from History", role: .destructive) { + app.sftpHistory.delete(entry.id) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + + Divider() + HStack { + Text("Double-click to open") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Button("Clear", role: .destructive) { confirmsClear = true } + .buttonStyle(.plain) + .font(.caption) + .disabled(entries.isEmpty) + } + .padding(.horizontal, Metrics.Space.m) + .padding(.vertical, Metrics.Space.s) + } + .confirmationDialog( + "Clear SFTP history for this Host?", isPresented: $confirmsClear, + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { + if let session { app.sftpHistory.clear(for: session.hostID) } + } + Button("Cancel", role: .cancel) {} + } + } + + private func reopen(_ entry: SFTPHistoryEntry) { + guard let session else { return } + switch entry.kind { + case .directory: + session.navigate(to: entry.path) + case .file: + session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers) { + if NSWorkspace.shared.open($0) { + session.recordOpenedFile(at: entry.path) + } + } + } + } +} + +private struct ConnectionHistorySidebar: View { + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + @AppStorage(SettingsKey.showRecents) private var showRecents = true + @State private var selectedID: Host.ID? + + private var hosts: [Host] { showRecents ? app.hostLibrary.recents(limit: 10) : [] } + + var body: some View { + Group { + if hosts.isEmpty { + ContentUnavailableView( + "No Recent Hosts", systemImage: "clock.arrow.circlepath", + description: Text("Hosts you connect to appear here.") + ) + } else { + List(hosts, selection: $selectedID) { host in + HStack(spacing: Metrics.Space.s) { + StatusDot(status: app.hostLibrary.status(of: host.id)) + VStack(alignment: .leading, spacing: 2) { + Text(host.name).lineLimit(1) + Text(app.hostLibrary.loginAddress(for: host)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(height: 42) + .contentShape(.rect) + .tag(host.id) + .onTapGesture(count: 2) { + app.open(host, as: .sshSession, in: window) + } + .contextMenu { HostContextMenu(host: host) } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + } + .overlay(alignment: .bottom) { + Text("Double-click to connect") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.vertical, Metrics.Space.s) + } + } +} + private struct CommandHistoryRow: View { let entry: CommandHistoryEntry let delete: () -> Void diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index b2ebeb63..911f17c1 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -978,7 +978,9 @@ struct SFTPBrowserView: View { sftpSession.navigate(to: file.path) } else { sftpSession.open(file, coordinator: app.sftpTransfers) { localURL in - NSWorkspace.shared.open(localURL) + if NSWorkspace.shared.open(localURL) { + sftpSession.recordOpenedFile(at: file.path) + } } } } diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 417752b7..c493bc8a 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -250,6 +250,7 @@ final class SFTPSessionModel: Identifiable { weak var navigationRouter: (any SFTPSessionNavigationRouting)? private weak var statusStore: HostLibraryModel? private weak var transferCoordinator: SFTPTransferCoordinator? + weak var historyStore: SFTPHistoryModel? private var didResolveInitialPath: Bool private let automaticRefreshInterval: Duration var connectionCoordinator: RemoteConnectionCoordinator? @@ -761,6 +762,9 @@ final class SFTPSessionModel: Identifiable { let snapshot = try await client.listDirectory(at: requestedPath) guard status == .connected, path == requestedPath else { return } replaceDirectoryContents(with: snapshot) + if !silently { + historyStore?.record(kind: .directory, path: requestedPath, for: hostID) + } hint = String(localized: "\(snapshot.count) items") } catch let error as SFTPError { if case .connectionLost(let message) = error { @@ -1081,11 +1085,38 @@ final class SFTPSessionModel: Identifiable { remotePath: file.path, byteCount: file.size, hostID: hostID, connection: connection, scopedRoot: directory, overwrite: false ) { state in - if state == .completed { completion(localURL) } + if state == .completed { + completion(localURL) + } } } catch { operationError = error.localizedDescription } } + func recordOpenedFile(at path: String) { + historyStore?.record(kind: .file, path: path, for: hostID) + } + + func reopenHistoryFile( + at path: String, coordinator: SFTPTransferCoordinator, + completion: @escaping (URL) -> Void + ) { + guard let client, status == .connected else { + operationError = String(localized: "The SFTP connection is not available.") + return + } + Task { + do { + guard let file = try await client.attributes(at: path), !file.isDirectory else { + operationError = String(localized: "The remote file is no longer available.") + return + } + open(file, coordinator: coordinator, completion: completion) + } catch { + operationError = error.localizedDescription + } + } + } + func downloadPromisedFile( _ file: RemoteFile, to destinationDirectory: URL, coordinator: SFTPTransferCoordinator, diff --git a/Termind/Features/Tabs/Pane.swift b/Termind/Features/Tabs/Pane.swift index 55a83f59..25683a64 100644 --- a/Termind/Features/Tabs/Pane.swift +++ b/Termind/Features/Tabs/Pane.swift @@ -122,6 +122,11 @@ final class Pane: Identifiable { session.commandHistoryStore = store } + func configureSFTPHistory(using store: SFTPHistoryModel) { + guard case .sftpSession(let session) = content else { return } + session.historyStore = store + } + var kind: PaneKind { switch content { case .sftpSession: .sftpSession diff --git a/Termind/Features/Tabs/WindowModel.swift b/Termind/Features/Tabs/WindowModel.swift index 790f2892..96e7b131 100644 --- a/Termind/Features/Tabs/WindowModel.swift +++ b/Termind/Features/Tabs/WindowModel.swift @@ -40,6 +40,12 @@ final class WindowModel { 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 assistantConversationDeletionHandler: ((ConversationModel.ID) -> Void)? var assistantPinHandler: ((ConversationModel.ID) -> Void)? var assistantRenameHandler: ((ConversationModel.ID, String) -> Void)? @@ -144,40 +150,23 @@ final class WindowModel { } var availableSidebarModes: [SidebarMode] { - sidebar.availableModes( - for: selectedDestination, - hasFocusedSSHSession: focusedSSHSession != nil - ) + sidebar.availableModes } var sidebarMode: SidebarMode { - get { - sidebar.mode( - for: selectedDestination, - hasFocusedSSHSession: focusedSSHSession != nil - ) - } + get { sidebar.mode } set { - sidebar.setMode( - newValue, - for: selectedDestination, - hasFocusedSSHSession: focusedSSHSession != nil - ) + guard sidebar.isModeEnabled(newValue) else { return } + sidebar.mode = newValue } } func isSidebarModeEnabled(_ mode: SidebarMode) -> Bool { - sidebar.isModeEnabled( - mode, for: selectedDestination, - hasFocusedSSHSession: focusedSSHSession != nil - ) + sidebar.isModeEnabled(mode) } func reconcileSidebarMode() { - sidebar.reconcileMode( - for: selectedDestination, - hasFocusedSSHSession: focusedSSHSession != nil - ) + sidebar.reconcileMode() } /// Opens the shared column and selects the current Workspace's assistant. @@ -395,6 +384,17 @@ final class WindowModel { selectedDestination = .assistant } + /// Promotes a compact Sidebar preview into the main Assistant surface while + /// keeping the conversation navigator visible in the shared Sidebar. + func openAssistantFullScreen(_ id: ConversationModel.ID? = nil) { + sidebarMode = .assistant + if let id { + selectAssistant(id) + } else { + _ = openNewAssistantChat() + } + } + func showAssistantArchive() { isAssistantArchivePresented = true selectedDestination = .assistant @@ -540,7 +540,10 @@ final class WindowModel { // MARK: - Opening @discardableResult - func open(_ workspace: Workspace, select shouldSelect: Bool = true) -> Workspace { + func open( + _ workspace: Workspace, + select shouldSelect: Bool = true + ) -> Workspace { workspace.lifecycleRouter = self if let connectionCoordinator { workspace.configureConnections(using: connectionCoordinator) @@ -548,6 +551,9 @@ final class WindowModel { if let commandHistoryStore { workspace.configureCommandHistory(using: commandHistoryStore) } + if let sftpHistoryStore { + workspace.configureSFTPHistory(using: sftpHistoryStore) + } workspaces.append(workspace) if shouldSelect { select(workspace.id) } return workspace @@ -664,6 +670,8 @@ final class WindowModel { ) -> Workspace { let pane = Pane.sftpSession(host: host, initialPath: initialPath) numberPane(pane) + if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) } + if let sftpHistoryStore { pane.configureSFTPHistory(using: sftpHistoryStore) } if placement == .currentWorkspace, let workspace = selectedWorkspace, diff --git a/Termind/Features/Tabs/WindowState.swift b/Termind/Features/Tabs/WindowState.swift index b0396692..4ec48985 100644 --- a/Termind/Features/Tabs/WindowState.swift +++ b/Termind/Features/Tabs/WindowState.swift @@ -44,7 +44,7 @@ nonisolated struct ContextPanelDetailID: Hashable, Sendable, Identifiable { /// The one left sidebar's window-lifetime state. /// /// Navigation and contextual tools share a physical column. The selected mode -/// is remembered per Workspace; Vault and Chats always resolve to Navigator. +/// belongs to the window and remains unchanged as its destination changes. @Observable final class WindowSidebarState { nonisolated enum Mode: String, Hashable, Sendable, CaseIterable, Identifiable { @@ -87,101 +87,22 @@ final class WindowSidebarState { /// selected so a stale host and directory cannot be left on screen. var sessionContext: SessionContext = .none - private var workspaceModes: [Workspace.ID: Mode] = [:] + var mode: Mode = .navigation private var selections: [VaultPage: [ContextPanelDetailID]] = [:] private var detailOverride: ContextPanelDetailID? private var detailOverridePaneID: Pane.ID? - func availableModes( - for destination: WindowDestination, - hasFocusedSSHSession: Bool - ) -> [Mode] { - switch destination { - case .vault, .assistant: - [.navigation] - case .workspace: - [.navigation, .assistant, .snippets, .history] - } + var availableModes: [Mode] { + Mode.allCases } - func isModeEnabled( - _ mode: Mode, for destination: WindowDestination, - hasFocusedSSHSession: Bool - ) -> Bool { - guard availableModes( - for: destination, hasFocusedSSHSession: hasFocusedSSHSession - ).contains(mode) else { return false } - if case .workspace = destination, - (mode == .snippets || mode == .history) - { - return hasFocusedSSHSession - } - return true + func isModeEnabled(_ mode: Mode) -> Bool { + availableModes.contains(mode) } - func mode( - for destination: WindowDestination, - hasFocusedSSHSession: Bool - ) -> Mode { - let stored: Mode - switch destination { - case .vault, .assistant: - return .navigation - case .workspace(let id): - stored = workspaceModes[id] ?? .navigation - } - let available = availableModes( - for: destination, - hasFocusedSSHSession: hasFocusedSSHSession - ) - if available.contains(stored), isModeEnabled( - stored, for: destination, - hasFocusedSSHSession: hasFocusedSSHSession - ) { return stored } - if (stored == .snippets || stored == .history), available.contains(.assistant) { - return .assistant - } - return available[0] - } - - func setMode( - _ mode: Mode, - for destination: WindowDestination, - hasFocusedSSHSession: Bool - ) { - guard isModeEnabled( - mode, for: destination, - hasFocusedSSHSession: hasFocusedSSHSession - ) else { return } - - switch destination { - case .vault, .assistant: - return - case .workspace(let id): - workspaceModes[id] = mode - } - } - - /// Commits a compatibility fallback, such as Snippets -> Assistant when an - /// SFTP pane takes focus, so returning to SSH does not resurrect a mode the - /// user has already been moved away from. - func reconcileMode( - for destination: WindowDestination, - hasFocusedSSHSession: Bool - ) { - guard case .workspace = destination else { - return - } - let resolved = mode( - for: destination, - hasFocusedSSHSession: hasFocusedSSHSession - ) - setMode( - resolved, - for: destination, - hasFocusedSSHSession: hasFocusedSSHSession - ) + func reconcileMode() { + // Every mode has a useful global or focus-sensitive presentation. } func selection(for page: VaultPage) -> [ContextPanelDetailID] { diff --git a/Termind/Features/Tabs/Workspace.swift b/Termind/Features/Tabs/Workspace.swift index 865c995b..3a686885 100644 --- a/Termind/Features/Tabs/Workspace.swift +++ b/Termind/Features/Tabs/Workspace.swift @@ -515,6 +515,10 @@ extension Workspace { for pane in panes { pane.configureCommandHistory(using: store) } } + func configureSFTPHistory(using store: SFTPHistoryModel) { + for pane in panes { pane.configureSFTPHistory(using: store) } + } + /// A host is required: there is no local terminal to open without one. static func sshSession(host: Host, usernameHint: String? = nil) -> Workspace { Workspace(pane: .sshSession(host: host, usernameHint: usernameHint)) diff --git a/Termind/Features/Tabs/WorkspaceNavigatorView.swift b/Termind/Features/Tabs/WorkspaceNavigatorView.swift index af2b2881..b1c6b89b 100644 --- a/Termind/Features/Tabs/WorkspaceNavigatorView.swift +++ b/Termind/Features/Tabs/WorkspaceNavigatorView.swift @@ -2,17 +2,14 @@ import AppKit import SwiftUI import UniformTypeIdentifiers -/// The native source list shared by Vault, Workspaces, Chats, and Recents. +/// The native source list for Vault and Workspaces. /// Workspace layout drops are accepted only inside the Workspace section. struct WorkspaceNavigatorView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window - @AppStorage(SettingsKey.showRecents) private var showRecents = true @AppStorage(SettingsKey.vaultSectionExpanded) private var vaultExpanded = true @AppStorage(SettingsKey.workspaceSectionExpanded) private var workspaceExpanded = true - @AppStorage(SettingsKey.chatsSectionExpanded) private var chatsExpanded = true - @AppStorage(SettingsKey.recentsSectionExpanded) private var recentsExpanded = true @State private var ghostIndex: Int? @State private var workspaceFrames: [Workspace.ID: CGRect] = [:] @@ -21,20 +18,14 @@ struct WorkspaceNavigatorView: View { @State private var renamingWorkspaceID: Workspace.ID? @State private var workspaceRenameTitle = "" - @State private var deletingConversation: ConversationModel? - @State private var renamingConversationID: ConversationModel.ID? - @State private var conversationRenameTitle = "" nonisolated private static let coordinateSpace = "termind.workspaceNavigator" var body: some View { List { + welcomeRow vaultSection workspaceSection - chatsSection - if showRecents { - recentsSection - } } .listStyle(.sidebar) .scrollContentBackground(.hidden) @@ -53,44 +44,31 @@ struct WorkspaceNavigatorView: View { .onChange(of: window.selectedDestination) { switch window.selectedDestination { case .vault: vaultExpanded = true - case .assistant: chatsExpanded = true + case .assistant: break case .workspace: workspaceExpanded = true } } .onChange(of: window.vaultDestination) { vaultExpanded = true } - .onChange(of: window.assistantLibrary.selectedConversationID) { - if window.selectedDestination == .assistant { chatsExpanded = true } - } - .onChange(of: visibleConversationIDs) { - if let renamingConversationID, - !visibleConversationIDs.contains(renamingConversationID) - { - cancelConversationRename() - } - } - .alert( - "Delete Conversation?", - isPresented: Binding( - get: { deletingConversation != nil }, - set: { if !$0 { deletingConversation = nil } } - ), - presenting: deletingConversation - ) { conversation in - Button("Cancel", role: .cancel) { deletingConversation = nil } - Button("Delete", role: .destructive) { - window.deleteAssistantConversation(conversation.id) - deletingConversation = nil - } - } message: { _ in - Text("This permanently removes the conversation and its scratch files.") - } } // MARK: - Vault + private var welcomeRow: some View { + navigatorRow( + title: VaultPage.welcome.label, + symbol: VaultPage.welcome.symbol, + count: 0, + isSelected: window.selectedDestination == .vault + && window.vaultDestination.page == .welcome + ) { + window.dragging = nil + window.openPage(.welcome) + } + } + private var vaultSection: some View { Section(isExpanded: $vaultExpanded) { - ForEach(VaultPage.allCases) { page in + ForEach(VaultPage.allCases.filter { $0 != .welcome }) { page in navigatorRow( title: page.label, symbol: page.symbol, @@ -249,118 +227,6 @@ struct WorkspaceNavigatorView: View { } } - // MARK: - Chats - - private var chatsSection: some View { - Section(isExpanded: $chatsExpanded) { - if window.assistantLibrary.pinnedConversations.isEmpty, - window.assistantLibrary.activeConversations.isEmpty - { - emptyRow("No Chats") - } - if !window.assistantLibrary.pinnedConversations.isEmpty { - subgroupLabel("Pinned") - ForEach(window.assistantLibrary.pinnedConversations) { conversation in - conversationRow(conversation) - } - } - if !window.assistantLibrary.activeConversations.isEmpty { - if !window.assistantLibrary.pinnedConversations.isEmpty { - subgroupLabel("Chats") - } - ForEach(window.assistantLibrary.activeConversations) { conversation in - conversationRow(conversation) - } - } - } header: { - sectionHeader("Chats") { - HStack(spacing: Metrics.Space.xs) { - Button { - chatsExpanded = true - window.openNewAssistantChat() - } label: { - Image(systemName: "plus.bubble") - } - .help(Text("New Chat")) - - Button { - chatsExpanded = true - window.showAssistantArchive() - } label: { - Image(systemName: "archivebox") - } - .help(Text("Archived")) - } - .buttonStyle(.borderless) - .controlSize(.small) - } - } - } - - private var visibleConversationIDs: Set { - Set( - (window.assistantLibrary.pinnedConversations - + window.assistantLibrary.activeConversations).map(\.id) - ) - } - - private func conversationRow(_ conversation: ConversationModel) -> some View { - AssistantConversationRow( - conversation: conversation, - isSelected: window.selectedDestination == .assistant - && !window.isAssistantArchivePresented - && window.assistantLibrary.selectedConversationID == conversation.id, - onOpen: { - window.dragging = nil - chatsExpanded = true - window.selectAssistant(conversation.id) - }, - onDelete: { deletingConversation = conversation }, - isRenaming: renamingConversationID == conversation.id, - renameTitle: $conversationRenameTitle, - onBeginRename: { beginConversationRename(conversation) }, - onCommitRename: { commitConversationRename(conversation) }, - onCancelRename: cancelConversationRename - ) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - - // MARK: - Recents - - private var recentsSection: some View { - Section(isExpanded: $recentsExpanded) { - if recentHosts.isEmpty { - emptyRow("No Recent Hosts") - } - ForEach(recentHosts) { host in - Button { - recentsExpanded = true - app.open(host, as: .sshSession, in: window) - } label: { - HStack(spacing: Metrics.Space.s) { - StatusDot(status: app.hostLibrary.status(of: host.id)) - Text(host.name).lineLimit(1) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: Metrics.Sidebar.navigationRowHeight) - .padding(.horizontal, Metrics.Sidebar.navigationRowInset) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { HostContextMenu(host: host) } - .help(Text("Quick Connect to \(app.hostLibrary.loginAddress(for: host))")) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - } header: { - sectionHeader("Recents") - } - } - - private var recentHosts: [Host] { app.hostLibrary.recents(limit: 10) } - // MARK: - Shared rows and editing private func navigatorRow( @@ -409,16 +275,6 @@ struct WorkspaceNavigatorView: View { .frame(maxWidth: .infinity) } - private func subgroupLabel(_ title: LocalizedStringKey) -> some View { - Text(title) - .termindSidebarSectionLabel() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Metrics.Sidebar.navigationRowInset) - .padding(.top, Metrics.Space.s) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - private func emptyRow(_ title: LocalizedStringKey) -> some View { Text(title) .font(.callout) @@ -446,22 +302,6 @@ struct WorkspaceNavigatorView: View { workspaceRenameTitle = "" } - private func beginConversationRename(_ conversation: ConversationModel) { - renamingConversationID = conversation.id - conversationRenameTitle = conversation.derivedTitle - } - - private func commitConversationRename(_ conversation: ConversationModel) { - let value = conversationRenameTitle.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty else { return } - window.renameAssistantConversation(conversation.id, to: value) - cancelConversationRename() - } - - private func cancelConversationRename() { - renamingConversationID = nil - conversationRenameTitle = "" - } } nonisolated enum WorkspaceSidebarDropGeometry { diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 484775d6..c46744a9 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -15,7 +15,6 @@ struct VaultSidebarView: View { private var items: [VaultNavigationItem] { var result: [VaultNavigationItem] = [ - .section(.welcome), .page(.welcome, count: 0, selected: window.vaultDestination.page == .welcome), .section(.vault), .page(.hosts, count: app.hostLibrary.hosts.count, selected: window.vaultDestination.page == .hosts), @@ -119,13 +118,11 @@ struct VaultSidebarView: View { nonisolated struct VaultNavigationItem: Identifiable, Hashable, Sendable { nonisolated enum Section: Hashable, Sendable { - case welcome case vault case recents var title: LocalizedStringKey { switch self { - case .welcome: "Welcome" case .vault: "Vault" case .recents: "Recents" } diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index fabc8a05..10a3fafc 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -355,9 +355,17 @@ enum TermindSchemaV3: VersionedSchema { } } +enum TermindSchemaV4: VersionedSchema { + static let versionIdentifier = Schema.Version(4, 0, 0) + + static var models: [any PersistentModel.Type] { + TermindSchemaV3.models + [PersistentSFTPHistoryEntry.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self] + [TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self, TermindSchemaV4.self] } static var stages: [MigrationStage] { @@ -367,6 +375,9 @@ enum TermindMigrationPlan: SchemaMigrationPlan { ), MigrationStage.lightweight( fromVersion: TermindSchemaV2.self, toVersion: TermindSchemaV3.self + ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV3.self, toVersion: TermindSchemaV4.self ) ] } @@ -374,7 +385,7 @@ enum TermindMigrationPlan: SchemaMigrationPlan { @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV3.self) + static let schema = Schema(versionedSchema: TermindSchemaV4.self) let context: ModelContext diff --git a/Termind/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift b/Termind/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift new file mode 100644 index 00000000..cecdeaad --- /dev/null +++ b/Termind/Infrastructure/Database/SwiftDataSFTPHistoryRepository.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftData + +@Model +final class PersistentSFTPHistoryEntry { + @Attribute(.unique) var id: UUID = UUID() + var hostID: UUID = UUID() + var kind = SFTPHistoryEntry.Kind.directory.rawValue + var path = "/" + var visitedAt = Date(timeIntervalSince1970: 0) + + init(_ value: SFTPHistoryEntry) { + id = value.id + hostID = value.hostID + kind = value.kind.rawValue + path = value.path + visitedAt = value.visitedAt + } + + var value: SFTPHistoryEntry { + SFTPHistoryEntry( + id: id, hostID: hostID, + kind: SFTPHistoryEntry.Kind(rawValue: kind) ?? .directory, + path: path, visitedAt: visitedAt + ) + } +} + +@MainActor +final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository { + private let context: ModelContext + init(context: ModelContext) { self.context = context } + + func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] { + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.hostID == hostID }, + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + )).map(\.value) + } + + func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws { + do { + let hostID = entry.hostID + let rows = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.hostID == hostID }, + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + )) + if let row = rows.first(where: { + $0.kind == entry.kind.rawValue && $0.path == entry.path + }) { + row.visitedAt = entry.visitedAt + } else { + context.insert(PersistentSFTPHistoryEntry(entry)) + } + try context.save() + + var overflow = FetchDescriptor( + predicate: #Predicate { $0.hostID == hostID }, + sortBy: [SortDescriptor(\.visitedAt, order: .reverse)] + ) + overflow.fetchOffset = limit + let oldRows = try context.fetch(overflow) + if !oldRows.isEmpty { + for row in oldRows { context.delete(row) } + try context.save() + } + } catch { context.rollback(); throw error } + } + + func delete(_ id: SFTPHistoryEntry.ID) throws { + do { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { context.delete(row) } + try context.save() + } catch { context.rollback(); throw error } + } + + func clear(for hostID: Host.ID) throws { + do { + try context.delete( + model: PersistentSFTPHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try context.save() + } catch { context.rollback(); throw error } + } +} + +extension SFTPHistoryModel { + convenience init(modelContext: ModelContext) { + self.init(repository: SwiftDataSFTPHistoryRepository(context: modelContext)) + } +} diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 445f4d21..7d3e70d9 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -2365,6 +2365,16 @@ } } }, + "Clear SFTP history for this Host?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除此主机的SFTP历史记录?" + } + } + } + }, "Click to insert · Double-click to execute" : { "localizations" : { "zh-Hans" : { @@ -3926,6 +3936,36 @@ } } }, + "Double-click to connect" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击连接" + } + } + } + }, + "Double-click to execute on default Targets" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击在默认Target上执行" + } + } + } + }, + "Double-click to open" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击打开" + } + } + } + }, "Double-click to rename" : { "localizations" : { "zh-Hans" : { @@ -4876,6 +4916,16 @@ } } }, + "Folders you visit and files you open appear here." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "访问过的文件夹和打开过的文件会显示在这里。" + } + } + } + }, "Follow Permissions" : { "localizations" : { "zh-Hans" : { @@ -5366,6 +5416,16 @@ } } }, + "Hosts you connect to appear here." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接过的主机会显示在这里。" + } + } + } + }, "Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs." : { "localizations" : { "zh-Hans" : { @@ -6715,6 +6775,7 @@ } }, "No Chats" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7074,6 +7135,16 @@ } } }, + "No SFTP History" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无SFTP历史记录" + } + } + } + }, "No shell in the foreground" : { "localizations" : { "zh-Hans" : { @@ -7434,6 +7505,16 @@ } } }, + "Open Full Screen" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全屏打开" + } + } + } + }, "Open in SFTP" : { "localizations" : { "zh-Hans" : { @@ -9493,6 +9574,16 @@ } } }, + "Search SFTP History" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索SFTP历史记录" + } + } + } + }, "Search Snippets" : { "localizations" : { "zh-Hans" : { @@ -11763,6 +11854,16 @@ } } }, + "The remote file is no longer available." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程文件已不存在。" + } + } + } + }, "The remote shell did not return its current input." : { "localizations" : { "zh-Hans" : { @@ -13589,4 +13690,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/TermindTests/ContextPanelSelectionTests.swift b/TermindTests/ContextPanelSelectionTests.swift index c888234c..17f13c86 100644 --- a/TermindTests/ContextPanelSelectionTests.swift +++ b/TermindTests/ContextPanelSelectionTests.swift @@ -120,51 +120,51 @@ struct ContextPanelSelectionTests { #expect(window.isSidebarModeEnabled(.history)) window.selectVault() - #expect(window.availableSidebarModes == [.navigation]) + #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) #expect(window.sidebarMode == .navigation) window.selectAssistant() - #expect(window.availableSidebarModes == [.navigation]) + #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) #expect(window.sidebarMode == .navigation) } @Test - func eachWorkspaceRemembersItsOwnModeAndVaultUsesNavigator() { + func sidebarModeIsSharedAcrossAllDestinations() { let first = Workspace.sshSession(host: Host(name: "First", host: "first.example")) let second = Workspace.sshSession(host: Host(name: "Second", host: "second.example")) let window = WindowModel(workspaces: [first, second]) window.sidebarMode = .history window.select(second.id) - window.sidebarMode = .snippets - window.selectVault() - - window.select(first.id) #expect(window.sidebarMode == .history) - window.select(second.id) - #expect(window.sidebarMode == .snippets) + window.selectVault() + #expect(window.sidebarMode == .history) + + window.selectAssistant() + #expect(window.sidebarMode == .history) + + window.sidebarMode = .navigation + window.openPage(.keychain) + #expect(window.selectedDestination == .vault) + #expect(window.vaultDestination == .keychain) #expect(window.sidebarMode == .navigation) } @Test - func unavailableCommandModesCommitAssistantFallback() { - let workspaceID = UUID() + func globalModesRemainSelectedWithoutAnSSHFocus() { let state = WindowSidebarState() for mode in [WindowSidebarState.Mode.snippets, .history] { - state.setMode(mode, for: .workspace(workspaceID), hasFocusedSSHSession: true) - state.reconcileMode(for: .workspace(workspaceID), hasFocusedSSHSession: false) + state.mode = mode + state.reconcileMode() - #expect( - state.mode(for: .workspace(workspaceID), hasFocusedSSHSession: true) - == .assistant - ) + #expect(state.mode == mode) } } @Test - func sftpKeepsFourStableIconsButDisablesCommandTools() { + func sftpEnablesAllFourSidebarModes() { let workspace = Workspace.sftpSession( host: Host(name: "Files", host: "files.example") ) @@ -173,8 +173,8 @@ struct ContextPanelSelectionTests { #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) #expect(window.isSidebarModeEnabled(.navigation)) #expect(window.isSidebarModeEnabled(.assistant)) - #expect(!window.isSidebarModeEnabled(.snippets)) - #expect(!window.isSidebarModeEnabled(.history)) + #expect(window.isSidebarModeEnabled(.snippets)) + #expect(window.isSidebarModeEnabled(.history)) } @Test diff --git a/TermindTests/SFTPHistoryTests.swift b/TermindTests/SFTPHistoryTests.swift new file mode 100644 index 00000000..2658bc94 --- /dev/null +++ b/TermindTests/SFTPHistoryTests.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftData +import Testing + +@testable import Termind + +@Suite +@MainActor +struct SFTPHistoryTests { + @Test + func recordsOnlyPathsAndRefreshesDuplicateRecency() { + let hostID = UUID() + let model = SFTPHistoryModel() + + model.record( + kind: .directory, path: "/srv/../srv/app", for: hostID, + at: Date(timeIntervalSince1970: 1) + ) + model.record( + kind: .file, path: "/srv/app/config.json", for: hostID, + at: Date(timeIntervalSince1970: 2) + ) + model.record( + kind: .directory, path: "/srv/app", for: hostID, + at: Date(timeIntervalSince1970: 3) + ) + + let entries = model.entries(for: hostID) + #expect(entries.count == 2) + #expect(entries.map(\.kind) == [.directory, .file]) + #expect(entries.map(\.path) == ["/srv/app", "/srv/app/config.json"]) + } + + @Test + func swiftDataRepositoryPersistsAndClearsPerHost() throws { + let configuration = localModelConfiguration( + "SFTPHistoryTests", schema: HostPersistence.schema, + isStoredInMemoryOnly: true + ) + let container = try ModelContainer( + for: HostPersistence.schema, configurations: [configuration] + ) + let hostID = UUID() + let otherHostID = UUID() + let model = SFTPHistoryModel(modelContext: container.mainContext) + + model.record(kind: .directory, path: "/var/log", for: hostID) + model.record(kind: .file, path: "/tmp/a.txt", for: otherHostID) + #expect(model.storageError == nil) + let rowCount = try container.mainContext.fetch(FetchDescriptor()).count + #expect(rowCount == 2) + #expect(model.entries(for: hostID).map(\.path) == ["/var/log"]) + + model.clear(for: hostID) + #expect(model.entries(for: hostID).isEmpty) + #expect(model.entries(for: otherHostID).map(\.path) == ["/tmp/a.txt"]) + } +} diff --git a/TermindTests/WindowStateTests.swift b/TermindTests/WindowStateTests.swift index 4200f2c1..bb4924b4 100644 --- a/TermindTests/WindowStateTests.swift +++ b/TermindTests/WindowStateTests.swift @@ -32,7 +32,7 @@ struct WindowStateTests { } @Test - func closingTheLastWorkspaceReturnsToVaultNavigator() { + func closingTheLastWorkspaceKeepsTheGlobalSidebarMode() { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) @@ -44,7 +44,7 @@ struct WindowStateTests { #expect(window.workspaces.isEmpty) #expect(window.selectedDestination == .vault) - #expect(window.sidebarMode == .navigation) + #expect(window.sidebarMode == .history) } @Test @@ -124,6 +124,38 @@ struct WindowStateTests { #expect(window.sidebarAssistantConversation === conversation) } + @Test + func openingSidebarConversationFullScreenKeepsAssistantSidebarSelected() { + let conversation = ConversationModel() + let library = AssistantLibraryModel(store: AssistantConversationStore( + conversations: [conversation] + )) + let window = WindowModel(assistantLibrary: library) + + window.openAssistantFullScreen(conversation.id) + + #expect(window.selectedDestination == .assistant) + #expect(window.assistantLibrary.selectedConversation === conversation) + #expect(window.sidebarMode == .assistant) + } + + @Test + func openingRecentHostKeepsTheGlobalHistoryMode() { + let host = Host(name: "Recent", host: "recent.example") + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + let window = WindowModel() + window.sidebarMode = .history + + let workspace = app.open(host, as: .sshSession, in: window) + + #expect(window.selectedDestination == .workspace(workspace.id)) + #expect(window.sidebarMode == .history) + + window.selectVault() + window.select(workspace.id) + #expect(window.sidebarMode == .history) + } + @Test func forkTitlesUseTheLowestFreeTabStyleNumber() { let source = ConversationModel()