Refine shared sidebar modes and history

This commit is contained in:
Kaede Akino committed 2026-08-28 16:19:34 +08:00
1 parent dbcb2cc60c
commit 86951641e4
21 files changed
+779 -348

No files matched your search

+10 -1
View File
@@ -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 {
+1
View File
@@ -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,
@@ -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 }
}
}
@@ -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 } }
}
@@ -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
}
}
+35 -4
View File
@@ -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() }
@@ -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
+3 -1
View File
@@ -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)
}
}
}
}
+32 -1
View File
@@ -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,
+5
View File
@@ -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
+32 -24
View File
@@ -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,
+8 -87
View File
@@ -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] {
+4
View File
@@ -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))
@@ -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<ConversationModel.ID> {
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 {
-3
View File
@@ -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"
}
@@ -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
@@ -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<PersistentSFTPHistoryEntry>(
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<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.save()
var overflow = FetchDescriptor<PersistentSFTPHistoryEntry>(
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<PersistentSFTPHistoryEntry>(
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))
}
}
+102 -1
View File
@@ -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"
}
}
+20 -20
View File
@@ -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
+58
View File
@@ -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<PersistentSFTPHistoryEntry>()).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"])
}
}
+34 -2
View File
@@ -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()