Refine shared sidebar modes and history
This commit is contained in:
1 parent
dbcb2cc60c
commit
86951641e4
21 files changed
+779
-348
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user