From d669c2efac5ed8994ba16d5668d73c3cd3a78597 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 07:17:28 +0800 Subject: [PATCH] chore: remove unused code --- .periphery.yml | 5 + Termind/App/MCPServerController.swift | 4 - Termind/App/SettingsKey.swift | 1 - .../Application/Agent/AgentConnections.swift | 2 - Termind/Application/Agent/AgentLoopHook.swift | 2 - .../Agent/AgentRunPersisting.swift | 1 - .../Agent/AgentToolArguments.swift | 4 - .../Application/Agent/AgentToolExecutor.swift | 7 - .../Application/Agent/AgentToolRegistry.swift | 2 - Termind/Application/Agent/UnifiedDiff.swift | 9 +- .../FeatureModels/AIConfigurationModel.swift | 8 - .../CredentialLibraryModel.swift | 6 - .../ForwardingLibraryModel.swift | 10 - .../FeatureModels/HostLibraryModel.swift | 96 ---- .../FeatureModels/SnippetLibraryModel.swift | 1 - .../FeatureModels/TransferLibraryModel.swift | 2 - Termind/Design/DesignTokens.swift | 42 -- Termind/Design/TerminalTheme.swift | 11 - Termind/Domain/AI/MCPServer.swift | 10 - .../Domain/AI/ModelCapabilityResolver.swift | 4 - Termind/Domain/AI/ModelProvider.swift | 7 - .../Connections/PortForwardingRule.swift | 10 - Termind/Domain/Hosts/KnownHost.swift | 13 - Termind/Domain/Transfers/SFTPTransfer.swift | 30 -- .../Features/AI/AgentToolPresentation.swift | 5 - Termind/Features/AI/ConversationModel.swift | 60 --- Termind/Features/AI/ConversationViews.swift | 8 - .../ContextPanelDetailCatalog.swift | 201 +-------- .../ContextPanel/ContextPanelView.swift | 417 ------------------ .../HostChooser/HostChooserView.swift | 2 - Termind/Features/Hosts/HostsPageView.swift | 4 - .../Features/Keychain/KeychainPageView.swift | 4 - Termind/Features/SFTP/SFTPBrowserView.swift | 4 - Termind/Features/SFTP/SFTPSessionModel.swift | 19 - .../Features/Snippets/SnippetsPageView.swift | 4 - Termind/Features/Tabs/WindowModel.swift | 71 --- Termind/Features/Tabs/WindowState.swift | 6 - Termind/Features/Tabs/Workspace.swift | 7 - .../Features/Terminal/SSHSessionModel.swift | 8 - Termind/Features/Vault/VaultView.swift | 285 ------------ .../Database/SwiftDataHostRepository.swift | 5 - .../LLM/AgentProviderClient.swift | 9 - .../LLM/GoogleServiceAccountAuth.swift | 18 - Termind/Infrastructure/SSH/SFTPClient.swift | 1 - .../Infrastructure/SSH/SSHExecSession.swift | 14 - .../Infrastructure/SSH/SSHSessionActor.swift | 86 ---- .../Security/KeychainSecretStore.swift | 10 - Termind/Resources/Localizable.xcstrings | 43 +- Termind/UI/Components/ManagedChooser.swift | 30 +- Termind/UI/Components/SharedChrome.swift | 12 - Termind/UI/Components/WindowToolbar.swift | 17 - Termind/UI/Managed/CardGrid.swift | 18 - Termind/UI/Managed/ManagedCardPage.swift | 4 - Termind/UI/Managed/PageFootnote.swift | 23 - TermindTests/AgentToolSchedulerTests.swift | 5 - 55 files changed, 30 insertions(+), 1657 deletions(-) create mode 100644 .periphery.yml delete mode 100644 Termind/UI/Managed/PageFootnote.swift diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 00000000..06f14b8f --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,5 @@ +project: Termind.xcodeproj +retain_public: true +schemes: +- Termind +- TermindMCPBridge diff --git a/Termind/App/MCPServerController.swift b/Termind/App/MCPServerController.swift index c6703004..86820e34 100644 --- a/Termind/App/MCPServerController.swift +++ b/Termind/App/MCPServerController.swift @@ -76,10 +76,6 @@ final class MCPServerController { ai.secret(for: Self.tokenReference) ?? "" } - var enabledToolNames: [String] { - MCPServerTools.exportedNames.subtracting(disabledTools).sorted() - } - func setEnabled(_ enabled: Bool) { isEnabled = enabled defaults.set(enabled, forKey: SettingsKey.mcpServerEnabled) diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 36d2719f..71942e07 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -28,7 +28,6 @@ nonisolated enum SettingsKey { static let chatsSectionExpanded = "sidebar.section.chats.expanded" static let pinnedChatsSectionExpanded = "sidebar.section.pinnedChats.expanded" static let snippetsSidebarCollapsedSections = "sidebar.snippets.collapsedSections" - static let recentsSectionExpanded = "sidebar.section.recents.expanded" /// Whether the floating status capsule shows its session details or is /// shrunk to its status dot. App-wide: it is a standing preference about /// how much of the pane you want covered, not a property of one session. diff --git a/Termind/Application/Agent/AgentConnections.swift b/Termind/Application/Agent/AgentConnections.swift index 90b4480d..2c8910ee 100644 --- a/Termind/Application/Agent/AgentConnections.swift +++ b/Termind/Application/Agent/AgentConnections.swift @@ -82,8 +82,6 @@ final class AgentHostResolver: Sendable { ) } - func targets() -> [AgentHostTarget] { listing().hosts } - /// Produces every valid path without trusting persisted parent links to be /// acyclic. A broken parent simply ends the path; a cycle never recurses. private func groupPaths() -> [HostGroup.ID: String] { diff --git a/Termind/Application/Agent/AgentLoopHook.swift b/Termind/Application/Agent/AgentLoopHook.swift index 9cd65e9f..f143f315 100644 --- a/Termind/Application/Agent/AgentLoopHook.swift +++ b/Termind/Application/Agent/AgentLoopHook.swift @@ -93,8 +93,6 @@ nonisolated struct AgentLoopHooks: Sendable { self.hooks = hooks } - var isEmpty: Bool { hooks.isEmpty } - /// First refusal wins, and the hooks after it are not consulted — a call /// that is already not happening has nothing left to decide. func willExecute(_ context: AgentToolCallContext) async -> AgentToolCallDecision { diff --git a/Termind/Application/Agent/AgentRunPersisting.swift b/Termind/Application/Agent/AgentRunPersisting.swift index 4abcb950..19d33894 100644 --- a/Termind/Application/Agent/AgentRunPersisting.swift +++ b/Termind/Application/Agent/AgentRunPersisting.swift @@ -195,7 +195,6 @@ actor InMemoryAgentRunRepository: AgentRunPersisting { titles[conversation.id] = conversation.title metadata[conversation.id] = conversation.metadata } - func title(_ conversationID: UUID) -> String? { titles[conversationID] } func deleteConversation(_ id: UUID) { deletedConversationIDs.insert(id) titles.removeValue(forKey: id) diff --git a/Termind/Application/Agent/AgentToolArguments.swift b/Termind/Application/Agent/AgentToolArguments.swift index a6e32ad5..6186ff1a 100644 --- a/Termind/Application/Agent/AgentToolArguments.swift +++ b/Termind/Application/Agent/AgentToolArguments.swift @@ -25,10 +25,6 @@ nonisolated struct Arguments: Sendable { func optionalString(_ key: String) -> String? { object[key]?.stringValue } func optionalInt(_ key: String) -> Int? { object[key]?.integerValue } func optionalBool(_ key: String) -> Bool? { object[key]?.boolValue } - func optionalStrings(_ key: String) -> [String] { - object[key]?.arrayValue?.compactMap(\.stringValue) ?? [] - } - /// The `questions` array of `request_user_input`. /// /// Everything malformed is rejected rather than dropped. A form silently diff --git a/Termind/Application/Agent/AgentToolExecutor.swift b/Termind/Application/Agent/AgentToolExecutor.swift index f7f0cb77..75567a46 100644 --- a/Termind/Application/Agent/AgentToolExecutor.swift +++ b/Termind/Application/Agent/AgentToolExecutor.swift @@ -21,13 +21,6 @@ nonisolated enum AgentPlannedToolCall: Sendable { var executionMetadata: [String: AgentJSONValue] } - var invocation: AgentToolInvocation { - switch self { - case .ready(let ready): ready.invocation - case .rejected(let invocation, _): invocation - } - } - /// The outcome a call already has. A planned call has none yet; the error a /// rejected one carries is final. var result: AgentToolResult { diff --git a/Termind/Application/Agent/AgentToolRegistry.swift b/Termind/Application/Agent/AgentToolRegistry.swift index afe75475..66ad028a 100644 --- a/Termind/Application/Agent/AgentToolRegistry.swift +++ b/Termind/Application/Agent/AgentToolRegistry.swift @@ -41,8 +41,6 @@ nonisolated struct AgentToolRegistry: Sendable { subscript(name: String) -> AnyAgentTool? { byName[name] } - var isEmpty: Bool { byName.isEmpty } - /// Sorted, because the tool list is part of the request's cacheable prefix /// and a dictionary's iteration order is not stable across launches. var descriptors: [AgentToolDescriptor] { diff --git a/Termind/Application/Agent/UnifiedDiff.swift b/Termind/Application/Agent/UnifiedDiff.swift index e0a18c50..480ac35b 100644 --- a/Termind/Application/Agent/UnifiedDiff.swift +++ b/Termind/Application/Agent/UnifiedDiff.swift @@ -5,13 +5,6 @@ import Foundation nonisolated enum LineEnding: String, Hashable, Sendable { case lf, crlf - var literal: String { - switch self { - case .lf: "\n" - case .crlf: "\r\n" - } - } - /// Whichever appears first, matching Pi's `detectLineEnding`. A mixed file is /// reported as whatever it leads with and normalized to that on write, which is /// the only answer that makes the file more consistent rather than less. @@ -157,7 +150,7 @@ nonisolated struct UnifiedDiff: Hashable, Sendable { /// The one difference that changes no line: a trailing newline gained or lost. private static func trailingNewlineOnly( - _ oldLines: [String], _ newLines: [String], + _: [String], _ newLines: [String], oldEndsWithNewline: Bool, newEndsWithNewline: Bool, fromPath: String, toPath: String, context: Int, maximumBytes: Int ) -> UnifiedDiff { diff --git a/Termind/Application/FeatureModels/AIConfigurationModel.swift b/Termind/Application/FeatureModels/AIConfigurationModel.swift index 7b0fe750..fe544261 100644 --- a/Termind/Application/FeatureModels/AIConfigurationModel.swift +++ b/Termind/Application/FeatureModels/AIConfigurationModel.swift @@ -175,10 +175,6 @@ final class AIConfigurationModel { AIRole.allCases.filter { roles[$0]?.modelProviderID == modelProviderID } } - func searchModelProviders(_ query: String) -> [ModelProvider] { - modelProviders.filter { $0.matches(query) } - } - func mcpServer(_ id: MCPServer.ID) -> MCPServer? { mcpServers.first { $0.id == id } } @@ -374,10 +370,6 @@ final class AIConfigurationModel { mutatePersistedState { roles[role] = ref } } - func clearRole(_ role: AIRole) { - mutatePersistedState { roles[role] = nil } - } - /// Records a choice made in a conversation's model menu. /// /// Not an assignment: it feeds step 3 of `resolve`, so it changes the diff --git a/Termind/Application/FeatureModels/CredentialLibraryModel.swift b/Termind/Application/FeatureModels/CredentialLibraryModel.swift index 0a660ce9..b94d2f03 100644 --- a/Termind/Application/FeatureModels/CredentialLibraryModel.swift +++ b/Termind/Application/FeatureModels/CredentialLibraryModel.swift @@ -19,7 +19,6 @@ final class CredentialLibraryModel { var storageError: String? { library.storageError } func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) } - func identity(_ id: Identity.ID) -> Identity? { library.identity(id) } func identities(using key: SSHKeyRef.ID) -> [Identity] { library.identities(using: key) } func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } @@ -41,11 +40,6 @@ final class CredentialLibraryModel { try library.sshConnectionConfiguration(for: host) } - @discardableResult - func upsert(_ identity: Identity) -> Bool { library.upsert(identity) } - func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { - try library.upsert(identity, passwordUpdate: passwordUpdate) - } func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { try library.upsert(key, credential: credential) } diff --git a/Termind/Application/FeatureModels/ForwardingLibraryModel.swift b/Termind/Application/FeatureModels/ForwardingLibraryModel.swift index 722c02c4..b533d4be 100644 --- a/Termind/Application/FeatureModels/ForwardingLibraryModel.swift +++ b/Termind/Application/FeatureModels/ForwardingLibraryModel.swift @@ -9,21 +9,11 @@ final class ForwardingLibraryModel { self.library = library } - var portForwardings: [PortForwardingRule] { library.portForwardings } - func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? { library.portForwarding(id) } func routeSummary(for host: Host) -> String { library.routeSummary(for: host) } - func sshCommand(for host: Host) -> String? { library.sshCommand(for: host) } func sshCommand(for rule: PortForwardingRule, host: Host? = nil) -> String? { library.sshCommand(for: rule, host: host) } - func upsert(_ forwarding: PortForwardingRule) { library.upsert(forwarding) } - func delete(portForwardingID: PortForwardingRule.ID) { - library.delete(portForwardingID: portForwardingID) - } - func duplicate(portForwardingID: PortForwardingRule.ID) -> PortForwardingRule? { - library.duplicate(portForwardingID: portForwardingID) - } } diff --git a/Termind/Application/FeatureModels/HostLibraryModel.swift b/Termind/Application/FeatureModels/HostLibraryModel.swift index 6687ad24..932fbc6d 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel.swift @@ -237,12 +237,6 @@ final class HostLibraryModel { return "\(username)@\(host.displayAddress)" } - /// Which hosts would lose their login if an identity were deleted — the - /// counterpart of `identities(using:)` one level up. - func hosts(using identity: Identity.ID) -> [Host] { - hosts.filter { $0.identityID == identity } - } - /// Which identities would break if a key were deleted. The Keys list shows /// this count, because "delete" needs to say what it takes with it. func identities(using key: SSHKeyRef.ID) -> [Identity] { @@ -373,12 +367,6 @@ final class HostLibraryModel { ) } - /// Resolves the already-saved hops needed to reach a destination whose own - /// trust or credentials may still be collected interactively by Terminal. - func sshJumpConfigurations(for host: Host) throws -> [SSHRouteHopConfiguration] { - try sshJumpConfigurations(for: host, submissions: [:], temporaryHostKeys: [:]) - } - func sshJumpConfigurations( for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], @@ -578,12 +566,6 @@ final class HostLibraryModel { return uniqueTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } - func hosts(taggedWith tag: String) -> [Host] { - hosts.filter { host in - host.tags.contains { $0.caseInsensitiveCompare(tag) == .orderedSame } - } - } - // MARK: - Search /// Also matches the resolved identity, so filtering by `deploy` still finds @@ -622,24 +604,6 @@ final class HostLibraryModel { } } - func searchSFTPTransfers(_ query: String) -> [SFTPTransfer] { - sftpTransfers.filter { $0.matches(query) }.sorted { $0.queuedAt > $1.queuedAt } - } - - /// Also matches the host's name, so filtering the all-hosts tunnel page by - /// `web-01` narrows to that machine's forwards — the thing you actually want - /// from a filter field on a page grouped by host. - func searchPortForwardings( - _ query: String, host scope: Host.ID? = nil - ) -> [PortForwardingRule] { - let candidates = scope.map { portForwardings(for: $0) } ?? portForwardings - guard !query.isEmpty else { return candidates } - return candidates.filter { portForwarding in - portForwarding.matches(query) - || host(portForwarding.hostID)?.name.localizedCaseInsensitiveContains(query) == true - } - } - // MARK: - Writes func upsert(_ host: Host) { @@ -1041,17 +1005,6 @@ final class HostLibraryModel { } } - /// The password behind an identity's `credentialRef`, loaded from Keychain. - func password(for identity: Identity) -> String? { - guard identity.usesPassword else { return nil } - do { - return try passwordCredential(for: identity) - } catch { - storageError = error.localizedDescription - return nil - } - } - /// Throwing counterpart used by connection setup, where a Keychain failure /// must remain distinguishable from an Identity that has no password. func passwordCredential(for identity: Identity) throws -> String? { @@ -1405,21 +1358,6 @@ final class HostLibraryModel { persistCore() } - func delete(portForwardingID: PortForwardingRule.ID) { - willDeletePortForwardings?([portForwardingID]) - portForwardings.removeAll { $0.id == portForwardingID } - persistCore() - } - - func duplicate(portForwardingID: PortForwardingRule.ID) -> PortForwardingRule? { - guard var copy = portForwarding(portForwardingID) else { return nil } - copy.id = UUID() - if !copy.label.isEmpty { copy.label += String(localized: " Copy") } - portForwardings.append(copy) - persistCore() - return copy - } - /// Root jump first and destination last. A nil result means the proposed /// host closes a cycle, references a missing jump, or exceeds eight hosts. func validatedRoute(for proposed: Host) -> [Host]? { @@ -1552,20 +1490,6 @@ final class HostLibraryModel { } } - /// Batch Startup for SFTP transfers. Skips anything already running or finished — - /// see `SFTPTransfer.State.isStartable`. - func startSFTPTransfers(_ ids: [SFTPTransfer.ID]) { - let wanted = Set(ids) - for index in sftpTransfers.indices - where wanted.contains(sftpTransfers[index].id) && sftpTransfers[index].state.isStartable { - sftpTransfers[index].state = .running - } - } - - func removeSFTPTransfer(_ id: SFTPTransfer.ID) { - sftpTransfers.removeAll { $0.id == id } - } - /// Clears terminal transfer records while leaving queued and running work /// untouched. The transfer center is a current-session history, not a log. func clearFinishedSFTPTransfers() { @@ -1577,26 +1501,6 @@ final class HostLibraryModel { } } - /// Moves `hostID` to sit between two neighbours, using the midpoint of their - /// fractional indices so no sibling has to be renumbered. - func move(hostID: Host.ID, into group: HostGroup.ID?, after previous: Host.ID?, before next: Host.ID?) { - guard let index = hosts.firstIndex(where: { $0.id == hostID }) else { return } - let lower = previous.flatMap { id in hosts.first { $0.id == id }?.sortIndex } - let upper = next.flatMap { id in hosts.first { $0.id == id }?.sortIndex } - - let newIndex: Double - switch (lower, upper) { - case (let lower?, let upper?): newIndex = (lower + upper) / 2 - case (let lower?, nil): newIndex = lower + 1 - case (nil, let upper?): newIndex = upper - 1 - case (nil, nil): newIndex = 0 - } - - hosts[index].groupID = group - hosts[index].sortIndex = newIndex - persistCore() - } - /// Files one or more hosts into a group, preserving the order supplied. /// /// `next` is the host that should follow the moved batch; `nil` appends it. diff --git a/Termind/Application/FeatureModels/SnippetLibraryModel.swift b/Termind/Application/FeatureModels/SnippetLibraryModel.swift index c6fa4bed..478432c2 100644 --- a/Termind/Application/FeatureModels/SnippetLibraryModel.swift +++ b/Termind/Application/FeatureModels/SnippetLibraryModel.swift @@ -15,7 +15,6 @@ final class SnippetLibraryModel { var rootSnippetPackages: [SnippetPackage] { library.rootSnippetPackages } var isSnippetsUngroupedExpanded: Bool { library.isSnippetsUngroupedExpanded } - func snippet(_ id: Snippet.ID) -> Snippet? { library.snippet(id) } func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { library.snippetPackage(id) } diff --git a/Termind/Application/FeatureModels/TransferLibraryModel.swift b/Termind/Application/FeatureModels/TransferLibraryModel.swift index bc23ee2b..6369221f 100644 --- a/Termind/Application/FeatureModels/TransferLibraryModel.swift +++ b/Termind/Application/FeatureModels/TransferLibraryModel.swift @@ -8,6 +8,4 @@ final class TransferLibraryModel { init(library: HostLibraryModel) { self.library = library } - - func host(_ id: Host.ID) -> Host? { library.host(id) } } diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index 7579b302..f81915fc 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -28,8 +28,6 @@ nonisolated enum Metrics { static let min: CGFloat = 260 static let ideal: CGFloat = 320 static let max: CGFloat = 520 - /// Color stripe on the leading edge of a host row. - static let stripeWidth: CGFloat = 2.5 static let statusDot: CGFloat = 7 /// Vault and Assistant navigation use one comfortable target so every /// destination, chat, archive, and new-chat action keeps a steady rhythm. @@ -123,28 +121,6 @@ nonisolated enum Metrics { static let height: CGFloat = 520 } - /// The small "make one now" sheets a picker raises — a new identity, a new - /// group. Narrower than the host chooser: three fields and no list to scan, - /// so the extra width would only stretch the text fields. - enum CreateSheet { - static let width: CGFloat = 400 - } - - /// The provider and MCP editors, which are a different animal from - /// `CreateSheet`: each holds a form *and* a list — dozens of fetched models, - /// or a server's discovered tools — and 400pt cannot show a model's name - /// beside its capabilities without one of them truncating. - /// - /// A fixed height rather than sizing to content, so the sheet does not jump - /// when a fetch turns two models into sixty. - enum EditorSheet { - static let width: CGFloat = 680 - static let height: CGFloat = 640 - /// The master column of the model list. Wide enough for a full model id - /// at caption size, which is what you scan by. - static let listWidth: CGFloat = 236 - } - /// Settings is a page in the window rather than its own panel, so it needs a /// reading width of its own — a `Form` stretched across a 1600pt display puts /// the label and its control at opposite ends of the desk. @@ -202,11 +178,7 @@ nonisolated enum Metrics { static let gutter: CGFloat = 56 /// The same idea at 260pt, where 56 would leave a bubble two words wide. static let compactGutter: CGFloat = 24 - /// The column a list marker sits in, so a wrapped second line aligns - /// under the text rather than under the bullet. - static let markerColumn: CGFloat = 20 static let codeInset: CGFloat = 10 - static let tableCellPadding: CGFloat = 6 /// The leading rule on a block quote and on expanded tool output. static let rule: CGFloat = 2 /// The caret shown while a reply is still arriving. @@ -284,7 +256,6 @@ nonisolated enum Metrics { enum VaultContent { static let min: CGFloat = 420 static let ideal: CGFloat = 680 - static let max: CGFloat = 1_000 } enum VaultDetail { @@ -479,15 +450,6 @@ struct SidebarMaterialBackground: NSViewRepresentable { } } -private struct TermindSidebarSurface: ViewModifier { - func body(content: Content) -> some View { - content.background { - SidebarMaterialBackground() - .ignoresSafeArea() - } - } -} - /// A native-feeling emphasized selection: the key window uses an accent fill /// with reversed content, while an inactive window keeps the same destination /// legible with a quiet accent wash and accent-colored content. @@ -616,10 +578,6 @@ extension View { modifier(TermindGlassBar()) } - func termindSidebarSurface() -> some View { - modifier(TermindSidebarSurface()) - } - func termindSidebarRowBackground(isSelected: Bool) -> some View { modifier(TermindSidebarRowBackground(isSelected: isSelected, menu: nil)) } diff --git a/Termind/Design/TerminalTheme.swift b/Termind/Design/TerminalTheme.swift index 770a222f..2590c553 100644 --- a/Termind/Design/TerminalTheme.swift +++ b/Termind/Design/TerminalTheme.swift @@ -82,17 +82,6 @@ nonisolated struct TerminalTheme: Identifiable, Hashable, Sendable { static let all: [TerminalTheme] = [.termindDark, .termindLight] - /// Convenience accessors for surrounding terminal UI. - func ansi(_ index: Int) -> RGB { ansi[max(0, min(ansi.count - 1, index))] } - - var red: RGB { ansi(1) } - var green: RGB { ansi(2) } - var yellow: RGB { ansi(3) } - var blue: RGB { ansi(4) } - var magenta: RGB { ansi(5) } - var cyan: RGB { ansi(6) } - var dim: RGB { ansi(8) } - /// The same palette expressed in the format consumed by libghostty. The /// SwiftUI chrome and the terminal grid therefore share one definition /// instead of merely using similarly named colors. diff --git a/Termind/Domain/AI/MCPServer.swift b/Termind/Domain/AI/MCPServer.swift index 0fed765e..eeba40ec 100644 --- a/Termind/Domain/AI/MCPServer.swift +++ b/Termind/Domain/AI/MCPServer.swift @@ -95,11 +95,6 @@ nonisolated struct MCPServer: Identifiable, Hashable, Sendable { !url.trimmingCharacters(in: .whitespaces).isEmpty } - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return name.lowercased().contains(q) || url.lowercased().contains(q) - } } /// Shared naming rules for MCP namespaces and their model-visible functions. @@ -206,11 +201,6 @@ nonisolated struct MCPTool: Identifiable, Hashable, Sendable, Codable { var annotations: MCPToolAnnotations = MCPToolAnnotations() var accessPolicy: AccessPolicy = .alwaysAsk - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return id.lowercased().contains(q) || summary.lowercased().contains(q) - } } /// Hints supplied by the remote MCP server. They inform risk signals but can diff --git a/Termind/Domain/AI/ModelCapabilityResolver.swift b/Termind/Domain/AI/ModelCapabilityResolver.swift index 202d3790..66c74b89 100644 --- a/Termind/Domain/AI/ModelCapabilityResolver.swift +++ b/Termind/Domain/AI/ModelCapabilityResolver.swift @@ -135,10 +135,6 @@ nonisolated enum ModelCapabilityResolver { return ReasoningProfile(levels: levels) } - static func customProfile(from resolution: Resolution) -> ReasoningProfile { - customProfile(from: resolution.reasoning) - } - private static func fill( _ model: AIModel, reported: ReportedFields, diff --git a/Termind/Domain/AI/ModelProvider.swift b/Termind/Domain/AI/ModelProvider.swift index 08fc5f0b..63e762ae 100644 --- a/Termind/Domain/AI/ModelProvider.swift +++ b/Termind/Domain/AI/ModelProvider.swift @@ -264,13 +264,6 @@ nonisolated struct ModelProvider: Identifiable, Hashable, Sendable { models.first { $0.id == id } } - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return name.lowercased().contains(q) - || kind.displayName.lowercased().contains(q) - || resolvedBaseURL.lowercased().contains(q) - } } /// One model on one provider. diff --git a/Termind/Domain/Connections/PortForwardingRule.swift b/Termind/Domain/Connections/PortForwardingRule.swift index c4c0e265..075d2467 100644 --- a/Termind/Domain/Connections/PortForwardingRule.swift +++ b/Termind/Domain/Connections/PortForwardingRule.swift @@ -52,17 +52,7 @@ nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, Sendable } } - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return label.lowercased().contains(q) - || summary.lowercased().contains(q) - || kind.rawValue.localizedCaseInsensitiveContains(q) - } - private static func address(_ host: String, port: Int) -> String { "\(host.contains(":") ? "[\(host)]" : host):\(port)" } } - -typealias PortForwardingKind = PortForwardingRule.Kind diff --git a/Termind/Domain/Hosts/KnownHost.swift b/Termind/Domain/Hosts/KnownHost.swift index 2df09dc9..860cc012 100644 --- a/Termind/Domain/Hosts/KnownHost.swift +++ b/Termind/Domain/Hosts/KnownHost.swift @@ -22,17 +22,4 @@ nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Sendable { SSHHostKeyIdentity(algorithm: algorithm, fingerprint: fingerprint) } - /// OpenSSH spells a non-default port `[host]:port`, and matching that is one - /// less thing to explain to someone importing their own `known_hosts`. - var displayAddress: String { - port == 22 ? hostname : "[\(hostname)]:\(port)" - } - - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return hostname.lowercased().contains(q) - || fingerprint.lowercased().contains(q) - || algorithm.rawValue.lowercased().contains(q) - } } diff --git a/Termind/Domain/Transfers/SFTPTransfer.swift b/Termind/Domain/Transfers/SFTPTransfer.swift index 303f640e..7c37d80d 100644 --- a/Termind/Domain/Transfers/SFTPTransfer.swift +++ b/Termind/Domain/Transfers/SFTPTransfer.swift @@ -12,14 +12,6 @@ nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { nonisolated var id: String { rawValue } - var displayName: String { - switch self { - case .upload: String(localized: "Upload") - case .download: String(localized: "Download") - case .copy: String(localized: "Copy") - } - } - var symbol: String { switch self { case .upload: "arrow.up.circle" @@ -48,15 +40,6 @@ nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { } } - /// Whether Batch Startup should pick this one up. Completed and already - /// running transfers are skipped; a failed one is a retry, which is - /// exactly what a batch start is for after a dropped connection. - var isStartable: Bool { - switch self { - case .queued, .paused, .failed: true - case .running, .completed, .cancelled: false - } - } } var id: UUID = UUID() @@ -85,14 +68,6 @@ nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { return min(1, Double(transferredBytes) / Double(byteCount)) } - var route: String { - switch direction { - case .upload: "\(localPath) → \(remotePath)" - case .download: "\(remotePath) → \(localPath)" - case .copy: "\(localPath) → \(remotePath)" - } - } - var relatedHostIDs: Set { Set([hostID, sourceHostID, destinationHostID].compactMap { $0 }) } @@ -101,9 +76,4 @@ nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { relatedHostIDs.contains(hostID) } - func matches(_ query: String) -> Bool { - guard !query.isEmpty else { return true } - let q = query.lowercased() - return localPath.lowercased().contains(q) || remotePath.lowercased().contains(q) - } } diff --git a/Termind/Features/AI/AgentToolPresentation.swift b/Termind/Features/AI/AgentToolPresentation.swift index d355d829..acf18452 100644 --- a/Termind/Features/AI/AgentToolPresentation.swift +++ b/Termind/Features/AI/AgentToolPresentation.swift @@ -158,11 +158,6 @@ nonisolated enum AgentToolPresentation { .joined(separator: ", ") } - static func output(from result: AgentToolResult) -> String { - let raw = result.untrustedPayload ?? result.content - return output(raw, policy: .automatic) - } - static func output(from result: AgentToolResult, presentation: AgentToolDescriptor.Presentation?) -> String { let raw = result.untrustedPayload ?? result.content return output(raw, policy: presentation?.output ?? .automatic) diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index e50c1fc8..9cce153c 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -252,36 +252,6 @@ nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable { case proposed, waitingForApproval, waitingForUser, reviewing, running, completed, denied, failed case interrupted - /// The transcript used to show `rawValue.capitalized`, which put the - /// case name itself on screen — `WaitingForApproval`. The raw value is - /// a storage key; this is the label. - var displayName: String { - switch self { - case .proposed: String(localized: "Proposed") - case .waitingForApproval: String(localized: "Waiting for approval") - case .waitingForUser: String(localized: "Waiting for authentication") - case .reviewing: String(localized: "Reviewing") - case .running: String(localized: "Running") - case .completed: String(localized: "Completed") - case .denied: String(localized: "Denied") - case .failed: String(localized: "Failed") - case .interrupted: String(localized: "Interrupted") - } - } - - /// Whether a card in this state opens itself. - /// - /// Anything still in flight, or anything that went wrong, is what the - /// reader is actually looking for. A call that simply worked is noise - /// once it has worked. - var revealsDetailByDefault: Bool { - switch self { - case .waitingForApproval, .waitingForUser, .reviewing, .running, .denied, .failed: true - case .interrupted: false - case .proposed, .completed: false - } - } - var isTerminal: Bool { switch self { case .completed, .denied, .failed, .interrupted: true @@ -359,32 +329,6 @@ nonisolated enum TimelineItem: Identifiable, Hashable, Sendable { } } - /// What the transcript scrolls to, and what a change to it must be visible - /// as: streaming text grows a message without changing any count. - var revision: Int { - var hasher = Hasher() - hasher.combine(id) - switch self { - case .message(let message): - hasher.combine(message.text.count) - hasher.combine(message.images.count) - hasher.combine(message.isStreaming) - case .tool(let card): - hasher.combine(card.state) - hasher.combine(card.result?.content.count ?? 0) - case .work(let group): - hasher.combine(group) - case .plan(let card): - // The outcome, so answering a plan re-triggers the scroll the same - // way a tool card finishing does. - hasher.combine(card.outcome) - case .compaction(let mark): - hasher.combine(mark.keptTokens) - case .compactionActivity(let activity): - hasher.combine(activity.state) - } - return hasher.finalize() - } } nonisolated struct AgentToolBatch: Identifiable, Hashable, Sendable { @@ -792,10 +736,6 @@ final class ConversationModel: Identifiable { runtimeState = runState } - var estimatedContextTokens: Int { - attachments.reduce(0) { $0 + $1.approximateTokens } - } - /// The generated summary if there is one, and the opening line until then. /// /// The fallback is not a placeholder to be replaced on refresh — a title is diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index 1dc90049..b9a52a72 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -1819,14 +1819,6 @@ struct ComposerView: View { } } - @ViewBuilder - private func selectionLabel(_ title: String, isSelected: Bool) -> some View { - if isSelected { - Label(title, systemImage: "checkmark") - } else { - Text(title) - } - } } nonisolated private struct AssistantModelOptionListItem: Identifiable, Hashable, Sendable { diff --git a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift index e230d462..6b942995 100644 --- a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift +++ b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift @@ -1,48 +1,20 @@ -import SwiftUI - -struct ContextPanelEmptyState { - let title: LocalizedStringKey - let symbol: String - let description: LocalizedStringKey -} - -struct ContextPanelDetailPresentation: Identifiable { - let id: ContextPanelDetailID - let label: AnyView - let content: AnyView -} +import Foundation protocol ContextPanelDetailProvider { var kind: String { get } - var page: VaultPage { get } - var emptyState: ContextPanelEmptyState { get } var availableIDs: Set { get } - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? } -/// Type-erased at the feature boundary so DetailSidebarView never imports or -/// switches over Host, Keychain, or Snippet models. struct AnyContextPanelDetailProvider: ContextPanelDetailProvider { let kind: String - let page: VaultPage - let emptyState: ContextPanelEmptyState private let available: () -> Set - private let resolve: (ContextPanelDetailID) -> ContextPanelDetailPresentation? init(_ provider: P) { kind = provider.kind - page = provider.page - emptyState = provider.emptyState available = { provider.availableIDs } - resolve = provider.presentation } var availableIDs: Set { available() } - - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - guard id.kind == kind else { return nil } - return resolve(id) - } } struct ContextPanelDetailCatalog { @@ -57,20 +29,6 @@ struct ContextPanelDetailCatalog { providers.reduce(into: []) { $0.formUnion($1.availableIDs) } } - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - providers.first { $0.kind == id.kind }?.presentation(for: id) - } - - func emptyState(for page: VaultPage?) -> ContextPanelEmptyState { - if let page, let state = providers.first(where: { $0.page == page })?.emptyState { - return state - } - return ContextPanelEmptyState( - title: "No Details", symbol: "sidebar.right", - description: "Select an item, or open content attached to a host." - ) - } - static func standard(store: HostLibraryModel) -> Self { Self([ AnyContextPanelDetailProvider(HostContextPanelDetailProvider(store: store)), @@ -84,184 +42,27 @@ struct ContextPanelDetailCatalog { private struct HostContextPanelDetailProvider: ContextPanelDetailProvider { let store: HostLibraryModel let kind = ContextPanelDetailID.Kind.host - let page = VaultPage.hosts - let emptyState = ContextPanelEmptyState( - title: "No Host Selected", symbol: "server.rack", - description: "Select one or more hosts to inspect them." - ) - var availableIDs: Set { Set(store.hosts.map { .host($0.id) }) } - - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - guard let hostID = id.hostID, let host = store.host(hostID) else { return nil } - return ContextPanelDetailPresentation( - id: id, - label: AnyView( - ContextPanelDetailLabel { - StatusDot(status: store.status(of: hostID)) - } title: { - ContextPanelDetailTitle( - title: host.name, subtitle: store.loginAddress(for: host) - ) - } - ), - content: AnyView(HostDetailContent(host: host)) - ) - } } private struct KeyContextPanelDetailProvider: ContextPanelDetailProvider { let store: HostLibraryModel let kind = ContextPanelDetailID.Kind.key - let page = VaultPage.keychain - let emptyState = ContextPanelEmptyState( - title: "No Key or Identity Selected", symbol: "key.fill", - description: "Select keys or identities to inspect them." - ) - var availableIDs: Set { Set(store.keys.map { .key($0.id) }) } - - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - guard let keyID = id.keyID, let key = store.key(keyID) else { return nil } - return ContextPanelDetailPresentation( - id: id, - label: AnyView( - ContextPanelDetailLabel { - Image(systemName: "key.fill").foregroundStyle(.orange) - } title: { - ContextPanelDetailTitle(title: key.label, subtitle: key.fingerprint) - } - ), - content: AnyView(KeyContextPanelDetailView(key: key)) - ) - } } private struct IdentityContextPanelDetailProvider: ContextPanelDetailProvider { let store: HostLibraryModel let kind = ContextPanelDetailID.Kind.identity - let page = VaultPage.keychain - let emptyState = ContextPanelEmptyState( - title: "No Key or Identity Selected", symbol: "key.fill", - description: "Select keys or identities to inspect them." - ) - var availableIDs: Set { Set(store.identities.map { .identity($0.id) }) } - - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - guard let identityID = id.identityID, let identity = store.identity(identityID) else { - return nil - } - return ContextPanelDetailPresentation( - id: id, - label: AnyView( - ContextPanelDetailLabel { - Image(systemName: "person.badge.key.fill").foregroundStyle(.accent) - } title: { - ContextPanelDetailTitle(title: identity.name, subtitle: identity.username) - } - ), - content: AnyView(IdentityContextPanelDetailView(identity: identity)) - ) - } } private struct SnippetContextPanelDetailProvider: ContextPanelDetailProvider { let store: HostLibraryModel let kind = ContextPanelDetailID.Kind.snippet - let page = VaultPage.snippets - let emptyState = ContextPanelEmptyState( - title: "No Snippet Selected", symbol: "curlybraces", - description: "Select a snippet to inspect it." - ) - var availableIDs: Set { Set(store.snippets.map { .snippet($0.id) }) } - - func presentation(for id: ContextPanelDetailID) -> ContextPanelDetailPresentation? { - guard let snippetID = id.snippetID, let snippet = store.snippet(snippetID) else { - return nil - } - return ContextPanelDetailPresentation( - id: id, - label: AnyView( - ContextPanelDetailLabel { - Image(systemName: "curlybraces").foregroundStyle(.accent) - } title: { - ContextPanelDetailTitle(title: snippet.title, subtitle: snippet.body) - } - ), - content: AnyView(SnippetContextPanelDetailView(snippet: snippet)) - ) - } -} - -private struct ContextPanelDetailLabel: View { - @ViewBuilder let icon: Icon - @ViewBuilder let title: Title - - var body: some View { - HStack(spacing: Metrics.Space.m) { - icon - title - } - } -} - -private struct ContextPanelDetailTitle: View { - let title: String - let subtitle: String - - var body: some View { - VStack(alignment: .leading, spacing: 1) { - Text(title).font(.headline).lineLimit(1) - Text(subtitle) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct KeyContextPanelDetailView: View { - let key: SSHKeyRef - @Environment(WindowModel.self) private var window - @State private var isExporting = false - - var body: some View { - KeyDetailContent( - key: key, - edit: { window.openVaultWork(.editKey(key.id), on: .keychain) }, - export: { isExporting = true } - ) - .sheet(isPresented: $isExporting) { ExportKeyToHostsSheet(keys: [key]) } - } -} - -private struct IdentityContextPanelDetailView: View { - let identity: Identity - @Environment(AppModel.self) private var app - @State private var isEditing = false - - var body: some View { - IdentityDetailContent(identity: identity) { isEditing = true } - .sheet(isPresented: $isEditing) { - NewIdentitySheet(identity: identity) { updated, passwordUpdate in - try app.hostLibrary.upsert(updated, passwordUpdate: passwordUpdate) - } - } - } -} - -private struct SnippetContextPanelDetailView: View { - let snippet: Snippet - - var body: some View { - SnippetDetailContent(snippet: snippet) - } } diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/ContextPanel/ContextPanelView.swift index e971287f..2e267476 100644 --- a/Termind/Features/ContextPanel/ContextPanelView.swift +++ b/Termind/Features/ContextPanel/ContextPanelView.swift @@ -1393,420 +1393,3 @@ private struct SSHSessionSnippetRow: View { .help(Text(snippet.body)) } } - -// MARK: - Details - -/// The Sidebar's Details mode describes every selected object without inventing -/// a primary item. Disclosure groups keep a large selection scannable while -/// still allowing several records to be read side by side. -struct DetailSidebarView: View { - let details: [ContextPanelDetailID] - let catalog: ContextPanelDetailCatalog - let page: VaultPage? - - @State private var expanded: Set = [] - - var body: some View { - Group { - if details.isEmpty { - emptyState - } else { - ScrollView { - VStack(alignment: .leading, spacing: Metrics.Space.l) { - Text(selectionSummary) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - - ForEach(details) { detail in - if let presentation = catalog.presentation(for: detail) { - DisclosureGroup(isExpanded: expansionBinding(for: detail)) { - presentation.content - .padding(.top, Metrics.Space.l) - .padding(.leading, Metrics.Space.xl) - } label: { - presentation.label - } - .padding(Metrics.Space.l) - .background( - .quaternary.opacity(0.28), - in: .rect(cornerRadius: Metrics.Radius.large) - ) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Metrics.Space.xl) - } - } - } - .labeledContentStyle(.automatic) - .onChange(of: details, initial: true) { _, current in - expanded.formIntersection(current) - if expanded.isEmpty, let first = current.first { expanded.insert(first) } - if current.count == 1, let only = current.first { expanded.insert(only) } - } - } - - private var selectionSummary: String { - details.count == 1 - ? String(localized: "1 item selected") - : String(localized: "\(details.count) items selected") - } - - private func expansionBinding(for detail: ContextPanelDetailID) -> Binding { - Binding( - get: { expanded.contains(detail) }, - set: { isExpanded in - if isExpanded { expanded.insert(detail) } else { expanded.remove(detail) } - } - ) - } - - private var emptyState: some View { - let content = catalog.emptyState(for: page) - return ContentUnavailableView { - Label(content.title, systemImage: content.symbol) - } description: { - Text(content.description) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -struct HostDetailContent: View { - @Environment(\.locale) private var locale - let host: Host - - @Environment(AppModel.self) private var app - @Environment(WindowModel.self) private var window - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.xl) { - HStack(spacing: Metrics.Space.s) { - Button("Quick Connect") { app.open(host, as: .sshSession, in: window) } - .buttonStyle(.borderedProminent) - HostConnectMenu(hosts: [host]) - Button("Edit") { window.openHostEditor(host: host) } - } - .controlSize(.small) - - LabeledContent("Status", value: app.hostLibrary.status(of: host.id).label) - LabeledContent("Port") { Text(host.port, format: .number.grouping(.never)) } - LabeledContent("Identity") { - if let identity = app.hostLibrary.identity(for: host) { - Text("\(identity.name) · \(identity.username)") - } else { - Text("Not set").foregroundStyle(.secondary) - } - } - if host.jumpHostID != nil || host.proxy != nil { - LabeledContent("Route") { - Text(app.hostLibrary.routeSummary(for: host)) - .font(.caption.monospaced()) - .multilineTextAlignment(.trailing) - } - } - LabeledContent("Last Used") { - Text( - host.lastUsedAt.map { $0.formatted(.relative(presentation: .named).locale(locale)) } - ?? String(localized: "Never")) - } - - if !host.tags.isEmpty { - DetailSection(title: "Tags") { - FlowLayout(spacing: Metrics.Space.xs) { - ForEach(host.tags, id: \.self) { tag in - Text(tag) - .font(.caption) - .padding(.horizontal, Metrics.Space.s) - .padding(.vertical, 2) - .background(.quaternary.opacity(0.5), in: .capsule) - } - } - } - } - - hostKey - - let portForwardings = app.hostLibrary.portForwardings(for: host.id) - if !portForwardings.isEmpty { - DetailSection(title: "Port Forwarding") { - ForEach(portForwardings, id: \.id) { portForwarding in - HStack(spacing: Metrics.Space.s) { - Image(systemName: portForwarding.kind.symbol) - .foregroundStyle( - app.forwarding.isEnabled(portForwarding.id) ? .green : .secondary - ) - Text(portForwarding.summary) - .font(.caption.monospaced()) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var hostKey: some View { - let entries = app.hostLibrary.knownHosts(for: host) - DetailSection(title: "Host Key") { - if !entries.isEmpty { - if entries.contains(where: \.isMismatched) { - Label( - "This server recently presented a key that is not in the trusted set.", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - ForEach(entries) { entry in - VStack(alignment: .leading, spacing: Metrics.Space.s) { - Text(entry.fingerprint) - .font(.caption.monospaced()) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - HStack(spacing: Metrics.Space.s) { - CardChip(text: entry.algorithm.displayName, symbol: "lock.shield") - Text(entry.addedAt, format: .relative(presentation: .named)) - .font(.caption2) - .foregroundStyle(.tertiary) - } - HStack(spacing: Metrics.Space.s) { - Button("Copy Fingerprint") { copyToPasteboard(entry.fingerprint) } - } - .controlSize(.small) - } - if entry.id != entries.last?.id { Divider() } - } - } else { - Text( - "No key saved yet. Termind saves it on first connection and warns if it changes." - ) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -struct KeyDetailContent: View { - let key: SSHKeyRef - let edit: () -> Void - let export: () -> Void - - @Environment(AppModel.self) private var app - - private var identities: [Identity] { app.hostLibrary.identities(using: key.id) } - - private var algorithm: String { - if key.algorithm == .rsa, let bits = key.keySize { return "RSA \(bits)" } - if let bits = key.keySize { return "\(key.algorithm.displayName) · \(bits) bit" } - return key.algorithm.displayName - } - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.xl) { - Menu("Actions") { - KeyObjectActions(key: key, edit: edit, export: export) - } - .menuStyle(.button) - .controlSize(.small) - - LabeledContent("Algorithm", value: algorithm) - LabeledContent("Stored in", value: key.storage.displayName) - LabeledContent("Created") { Text(key.createdAt, format: .dateTime) } - LabeledContent("Passphrase") { - Text( - !key.hasPassphrase - ? String(localized: "None") - : String(localized: "Required at use") - ) - } - LabeledContent("Authentication") { - Text(key.requiresUserPresence ? String(localized: "Required") : String(localized: "Not required")) - } - - DetailTextBlock(title: "Fingerprint", text: key.fingerprint) - DetailTextBlock( - title: "Public Key", - text: key.publicKey.isEmpty ? String(localized: "Not available") : key.publicKey - ) - if !key.certificate.isEmpty { - DetailTextBlock(title: "Certificate", text: key.certificate) - if let certificate = try? SSHUserCertificate.parse( - key.certificate, matching: key.publicKey - ), let warning = certificate.validityWarning { - Label(warning, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } - } - - DetailSection(title: "Used by Identities") { - if identities.isEmpty { - Text("None").font(.caption).foregroundStyle(.secondary) - } else { - ForEach(identities) { identity in - Label("\(identity.name) · \(identity.username)", systemImage: "person.badge.key.fill") - .font(.caption) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct IdentityDetailContent: View { - @Environment(\.locale) private var locale - let identity: Identity - let edit: () -> Void - - @Environment(AppModel.self) private var app - - private var key: SSHKeyRef? { identity.keyID.flatMap { app.hostLibrary.key($0) } } - private var hosts: [Host] { app.hostLibrary.hosts(using: identity.id) } - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.xl) { - Button("Edit", action: edit) - .controlSize(.small) - - LabeledContent("Username", value: identity.username) - LabeledContent("Password") { - Text(passwordStatus) - .foregroundStyle(passwordStatusIsWarning ? .orange : .primary) - } - LabeledContent("Key") { - if let key { - Text(key.label) - } else if identity.usesKey { - Text("Missing").foregroundStyle(.orange) - } else { - Text("Not configured").foregroundStyle(.secondary) - } - } - LabeledContent("Last Used") { - Text( - identity.lastUsedAt.map { $0.formatted(.relative(presentation: .named).locale(locale)) } - ?? String(localized: "Never")) - } - - if !identity.notes.isEmpty { DetailTextBlock(title: "Notes", text: identity.notes, monospaced: false) } - - DetailSection(title: "Used by Hosts") { - if hosts.isEmpty { - Text("None").font(.caption).foregroundStyle(.secondary) - } else { - ForEach(hosts) { host in - Label(host.name, systemImage: "server.rack") - .font(.caption) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var passwordStatus: String { - switch app.hostLibrary.passwordAvailability(for: identity) { - case .notConfigured: String(localized: "Not configured") - case .available: String(localized: "Configured") - case .missing: String(localized: "Not available on this Mac") - case .failed: String(localized: "Keychain unavailable") - } - } - - private var passwordStatusIsWarning: Bool { - switch app.hostLibrary.passwordAvailability(for: identity) { - case .missing, .failed: true - case .notConfigured, .available: false - } - } -} - -struct SnippetDetailContent: View { - let snippet: Snippet - - @Environment(AppModel.self) private var app - - private var assessment: RiskAssessment { CommandRiskClassifier.assess(snippet.body) } - private var targets: [Host] { snippet.targetHostIDs.compactMap { app.hostLibrary.host($0) } } - private var packagePath: String { - app.hostLibrary.snippetPackagePath(snippet.packageID) ?? String(localized: "Ungrouped") - } - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.xl) { - LabeledContent("Package", value: packagePath) - LabeledContent("Risk") { RiskBadge(assessment: assessment) } - - DetailTextBlock(title: "Scripts", text: snippet.body) - - if !assessment.reasons.isEmpty { - DetailSection(title: "Risk Assessment") { - ForEach(assessment.reasons, id: \.self) { reason in - Text("• \(reason)") - .font(.caption) - .foregroundStyle(assessment.risk.tint) - } - } - } - - DetailSection(title: "Targets") { - if targets.isEmpty { - Text("Choose hosts when you execute this snippet") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(targets) { host in - VStack(alignment: .leading, spacing: 1) { - Label(host.name, systemImage: "server.rack") - Text(app.hostLibrary.loginAddress(for: host)) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .padding(.leading, 20) - } - .font(.caption) - } - } - } - - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct DetailSection: View { - let title: LocalizedStringKey - @ViewBuilder let content: Content - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.s) { - Text(title).font(.caption.weight(.medium)).foregroundStyle(.secondary) - content - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -struct DetailTextBlock: View { - let title: LocalizedStringKey - let text: String - var monospaced = true - - var body: some View { - DetailSection(title: title) { - Text(text) - .font(monospaced ? .caption.monospaced() : .caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} diff --git a/Termind/Features/HostChooser/HostChooserView.swift b/Termind/Features/HostChooser/HostChooserView.swift index 1a8d11f8..fa1a9215 100644 --- a/Termind/Features/HostChooser/HostChooserView.swift +++ b/Termind/Features/HostChooser/HostChooserView.swift @@ -148,8 +148,6 @@ struct HostChooserView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.dismiss) private var dismiss - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var chooser: ManagedChooserState @State private var connectionProtocol = HostConnectionProtocol.ssh @State private var isConfirmingDestructiveSnippet = false diff --git a/Termind/Features/Hosts/HostsPageView.swift b/Termind/Features/Hosts/HostsPageView.swift index 23ce868c..03c2524e 100644 --- a/Termind/Features/Hosts/HostsPageView.swift +++ b/Termind/Features/Hosts/HostsPageView.swift @@ -327,10 +327,6 @@ struct HostsPageView: ManagedCardPage { sections.flatMap(\.hosts) } - private var selectedHosts: [Host] { - visibleHosts.filter { hostSelection.contains($0.id) } - } - private func setHostSelection(_ ids: Set) { let valid = ids.intersection(Set(app.hostLibrary.hosts.map(\.id))) state.selectedGroupID = nil diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 95f72ba9..3dfec0e6 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -209,10 +209,6 @@ struct KeychainPageView: ManagedCardPage { window.setDetailSelection(ordered, for: .keychain) } - private func select(_ item: ContextPanelDetailID) { - window.selectDetail(item, on: .keychain) - } - private func requestExport(_ keys: [SSHKeyRef]) { guard !keys.isEmpty else { return } window.openVaultWork(.exportKeys(keys.map(\.id)), on: .keychain) diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index 06b1f774..57446a6f 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -1238,10 +1238,6 @@ struct SFTPBrowserView: View { } } - private func chooseDownloadDestination(for file: RemoteFile) { - chooseDownloadDestination(for: [file]) - } - private func chooseDownloadDestination(for files: [RemoteFile]) { let panel = NSOpenPanel() panel.canChooseFiles = false diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index c493bc8a..3d23a3ae 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -281,7 +281,6 @@ final class SFTPSessionModel: Identifiable { var canGoBack: Bool { !backHistory.isEmpty } var canGoForward: Bool { !forwardHistory.isEmpty } - var canGoUp: Bool { path != "/" } var conflictCount: Int { pendingRemoteCopyBatch?.conflictCount ?? pendingBatch?.conflictCount ?? 0 } @@ -705,15 +704,6 @@ final class SFTPSessionModel: Identifiable { ) } - func cancelAuthentication() { - reconnectController.stop() - isRecoveringConnection = false - pendingAuthentication = nil - suppliedAuthentications.removeAll() - status = .disconnected - connectionJourney.hide() - } - func navigate(to destination: String) { let destination = RemotePath.normalized(destination) guard destination != path else { @@ -746,7 +736,6 @@ final class SFTPSessionModel: Identifiable { reloadNow() } - func goUp() { navigate(to: RemotePath.parent(of: path)) } func commitPathText() { navigate(to: pathText) } func reloadNow() { Task { await reload() } } @@ -848,10 +837,6 @@ final class SFTPSessionModel: Identifiable { } } - func delete(_ file: RemoteFile) async { - await delete([file]) - } - func delete(_ files: [RemoteFile]) async { guard let client else { return } let cancellation = TransferCancellationToken() @@ -953,10 +938,6 @@ final class SFTPSessionModel: Identifiable { } catch { operationError = error.localizedDescription } } - func prepareDownload(_ file: RemoteFile, to directory: URL) async { - await prepareDownload([file], to: directory) - } - func prepareDownload(_ files: [RemoteFile], to directory: URL) async { guard let client else { return } var jobs: [PreparedJob] = [] diff --git a/Termind/Features/Snippets/SnippetsPageView.swift b/Termind/Features/Snippets/SnippetsPageView.swift index 6f9cd149..2e4d5226 100644 --- a/Termind/Features/Snippets/SnippetsPageView.swift +++ b/Termind/Features/Snippets/SnippetsPageView.swift @@ -281,10 +281,6 @@ struct SnippetsPageView: ManagedCardPage { private var visibleSnippets: [Snippet] { sections.flatMap(\.snippets) } - private var selectedSnippets: [Snippet] { - visibleSnippets.filter { selection.contains($0.id) } - } - private func setSelection(_ ids: Set) { let valid = ids.intersection(Set(app.snippets.snippets.map(\.id))) state.selectedPackageID = nil diff --git a/Termind/Features/Tabs/WindowModel.swift b/Termind/Features/Tabs/WindowModel.swift index e70f19e2..be1d345f 100644 --- a/Termind/Features/Tabs/WindowModel.swift +++ b/Termind/Features/Tabs/WindowModel.swift @@ -115,10 +115,6 @@ final class WindowModel { return sidebar.activeDetails(for: selectedWorkspace?.focusedPane) } - func clearSidebarDetailOverride() { - sidebar.clearOverride() - } - /// Resolves the active ids against live store data without mutating the /// remembered selection. This makes deletion safe even before the next /// pruning pass runs. @@ -233,11 +229,6 @@ final class WindowModel { } } - var selectedIndex: Int? { - guard let selectedWorkspaceID else { return nil } - return workspaces.firstIndex { $0.id == selectedWorkspaceID } - } - /// The only pane that application-wide find commands may touch. A Workspace /// can contain several terminals, so routing to "any session in the workspace" /// would make ⌘F search a buffer other than the one under the caret. @@ -367,8 +358,6 @@ final class WindowModel { return selectedWorkspace } - var isEmpty: Bool { workspaces.isEmpty } - /// Every pane of a given kind, with the Workspace holding it. /// /// The sidebar lists *panes*, not workspaces. A Workspace can be mixed — a shell @@ -411,42 +400,6 @@ final class WindowModel { var canReopen: Bool { !reopenStack.isEmpty } - /// The most recently opened SSH session, with the workspace holding it. - /// - /// The Snippets page is itself the front workspace when a snippet is inserted, so - /// "the session" cannot mean "the front workspace". Most-recent is the best - /// answer available at P0; once sessions are real this becomes the last - /// *focused* one. - private var lastSSHSession: (workspace: Workspace, sshSession: SSHSessionModel)? { - for workspace in workspaces.reversed() { - for pane in workspace.panes.reversed() { - if let sshSession = pane.sshSession { - return (workspace, sshSession) - } - } - } - return nil - } - - var hasSSHSession: Bool { lastSSHSession != nil } - - /// Types a command onto a session's prompt and brings that workspace forward. - /// - /// It never sends the command — pressing Return stays the user's decision, - /// the same rule the AI command cards follow. Returns `false` when there is - /// no session to type into, so the caller can fall back to the pasteboard. - /// - /// Through `Workspace.deliver` rather than straight at the session, so a - /// snippet inserted into a broadcasting Workspace lands in every shell in - /// it. Every route text takes into a prompt goes through that one door. - @discardableResult - func insertIntoSSHSession(_ command: String) -> Bool { - guard let (workspace, sshSession) = lastSSHSession else { return false } - workspace.deliver(command, from: sshSession) - select(workspace.id) - return true - } - func select(_ id: Workspace.ID) { guard workspaces.contains(where: { $0.id == id }) else { return } selectedDestination = .workspace(id) @@ -808,10 +761,6 @@ final class WindowModel { openVaultWork(.hostEditor(host?.id), on: .hosts) } - func closeManagement() { - closeVaultWork() - } - // MARK: - Combining /// Folds every pane of `source` into `target` beside a named pane, on a named @@ -1012,9 +961,6 @@ final class WindowModel { var canDuplicateSelected: Bool { canSplitSelected } - /// Whether ⌘B has a group to act on — see `Workspace.canBroadcast`. - var canBroadcastSelected: Bool { selectedWorkspace?.canBroadcast ?? false } - /// Carves an empty slot beside the focused pane, for something already open /// to be dragged into. Deliberately not a connection: choosing a layout and /// choosing a machine are two decisions, and only one of them was asked for. @@ -1179,15 +1125,6 @@ final class WindowModel { } } - /// Closes every SSH and SFTP session pane, leaving manager pages open. No - /// confirmation: ⇧⌘T reopens the panes, while closing them disconnects the - /// active connections. - func closeAllSessions() { - for ref in sessionPanes { - closePane(ref.pane.id) - } - } - func closeWorkspaces(for hostID: Host.ID) { let paneIDs = workspaces.flatMap(\.panes).filter { $0.hostID == hostID }.map(\.id) for paneID in paneIDs { closePane(paneID) } @@ -1206,10 +1143,6 @@ final class WindowModel { // MARK: - Reordering - func move(from source: IndexSet, to destination: Int) { - workspaces.move(fromOffsets: source, toOffset: destination) - } - /// Drag-reorder in the Navigator: puts `id` at `index` in the Workspace /// list after excluding the source workspace from insertion-index geometry. /// @@ -1301,8 +1234,4 @@ struct PaneRef: Identifiable { var id: Pane.ID { pane.id } - /// True when this pane shares its Workspace with others, which is when the - /// row needs to say so — otherwise every row would carry a badge that means - /// nothing. - var isInWorkspace: Bool { workspace.isSplit } } diff --git a/Termind/Features/Tabs/WindowState.swift b/Termind/Features/Tabs/WindowState.swift index 848576a0..e8f04e45 100644 --- a/Termind/Features/Tabs/WindowState.swift +++ b/Termind/Features/Tabs/WindowState.swift @@ -158,12 +158,6 @@ final class WindowSidebarState { detailOverridePaneID = nil } - func validActiveDetails(for pane: Pane?, available: Set) - -> [ContextPanelDetailID] - { - activeDetails(for: pane).filter(available.contains) - } - func pruneSelections(available: Set) { for page in VaultPage.allCases { let valid = selection(for: page).filter(available.contains) diff --git a/Termind/Features/Tabs/Workspace.swift b/Termind/Features/Tabs/Workspace.swift index 3a686885..4159f3f4 100644 --- a/Termind/Features/Tabs/Workspace.swift +++ b/Termind/Features/Tabs/Workspace.swift @@ -109,19 +109,12 @@ final class Workspace: Identifiable { var symbol: String { isSplit ? "rectangle.split.2x1" : focusedPane.symbol } - /// Which sidebar section lists this workspace, if any. - var kind: PaneKind { focusedPane.kind } - var status: ConnectionStatus { focusedPane.status } var hasUnreadOutput: Bool { panes.contains { $0.hasUnreadOutput } } - var hostID: Host.ID? { focusedPane.hostID } - - var workingDirectory: String? { focusedPane.workingDirectory } - /// A split workspace is named for itself, not for whichever half you clicked last. /// /// Reading the focused pane meant one workspace wore two names depending on where diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 88f7508b..a9ce2074 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -884,14 +884,6 @@ final class SSHSessionModel: Identifiable { connect(using: store) } - func cancelAuthentication() { - pendingAuthentication = nil - suppliedAuthentications.removeAll() - pendingInitialCommand = nil - status = .disconnected - connectionJourney.hide() - } - func trustAndConnect(_ observation: SSHHostKeyObservation, using store: HostLibraryModel) { store.trust( KnownHostEntry( diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 74b7457a..0b32ca9a 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -1,289 +1,4 @@ -import AppKit -import ListViewKit import SwiftUI - -/// Vault's navigation mode inside the window's one shared sidebar. -struct VaultSidebarView: View { - @Environment(AppModel.self) private var app - @Environment(WindowModel.self) private var window - @AppStorage(SettingsKey.showRecents) private var showRecents = true - var body: some View { - VaultNavigationNativeList(items: items) { item in - renderedItem(item) - } - } - - private var items: [VaultNavigationItem] { - var result: [VaultNavigationItem] = [ - .page(.welcome, count: 0, selected: window.vaultDestination.page == .welcome), - .section(.vault), - .page(.hosts, count: app.hostLibrary.hosts.count, selected: window.vaultDestination.page == .hosts), - .page( - .keychain, - count: app.hostLibrary.keys.count + app.hostLibrary.identities.count, - selected: window.vaultDestination.page == .keychain - ), - .page(.snippets, count: app.hostLibrary.snippets.count, selected: window.vaultDestination.page == .snippets), - ] - if showRecents, !recentHosts.isEmpty { - result.append(.section(.recents)) - result.append(contentsOf: recentHosts.map { host in - .recent( - host, - address: app.hostLibrary.loginAddress(for: host), - status: app.hostLibrary.status(of: host.id) - ) - }) - } - return result - } - - @ViewBuilder - private func renderedItem(_ item: VaultNavigationItem) -> some View { - switch item.content { - case .section(let section): - Text(section.title) - .termindSidebarSectionLabel() - .frame(maxWidth: .infinity, alignment: .leading) - .padding( - .horizontal, - Metrics.Sidebar.navigationRowInset + Metrics.Sidebar.rowHorizontalMargin - ) - .padding(.top, Metrics.Space.m) - .padding(.bottom, Metrics.Space.xs) - case .page(let page, let count, _): - row(page, count: count) - case .recent(let host, _, _): - recentRow(host) - } - } - - private func row(_ page: VaultPage, count: Int = 0) -> some View { - let isSelected = window.vaultDestination.page == page - - return Button { - window.openPage(page) - } label: { - HStack(spacing: Metrics.Space.s) { - Label(page.label, systemImage: page.symbol) - Spacer(minLength: Metrics.Space.xs) - if count > 0 { - Text(count, format: .number) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: Metrics.Sidebar.navigationRowHeight) - .padding(.horizontal, Metrics.Sidebar.navigationRowInset) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .termindSidebarRowBackground(isSelected: isSelected) - } - - private var recentHosts: [Host] { - app.hostLibrary.recents(limit: 5) - } - - /// Recents are actions rather than Vault destinations: activating one opens - /// a full-width SSH Workspace while Vault remains the home for host data. - private func recentRow(_ host: Host) -> some View { - Button { - app.open(host, as: .sshSession, in: window) - } label: { - HStack(spacing: Metrics.Space.s) { - Text(host.name) - .lineLimit(1) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: Metrics.Sidebar.navigationRowHeight) - .padding(.horizontal, Metrics.Sidebar.navigationRowInset) - .contentShape(.rect) - } - .buttonStyle(.plain) - .contextMenu { - HostContextMenu(host: host) - } - .termindSidebarRowBackground(isSelected: false) - .help(Text("Quick Connect to \(app.hostLibrary.loginAddress(for: host))")) - .accessibilityHint("Quick Connect with SSH") - } - -} - -nonisolated struct VaultNavigationItem: Identifiable, Hashable, Sendable { - nonisolated enum Section: Hashable, Sendable { - case vault - case recents - - var title: LocalizedStringKey { - switch self { - case .vault: "Vault" - case .recents: "Recents" - } - } - } - - nonisolated enum ID: Hashable, Sendable { - case section(Section) - case page(VaultPage) - case recent(Host.ID) - } - - nonisolated enum Content: Hashable, Sendable { - case section(Section) - case page(VaultPage, count: Int, selected: Bool) - case recent(Host, address: String, status: ConnectionStatus) - } - - let id: ID - let content: Content - - static func section(_ section: Section) -> Self { - .init(id: .section(section), content: .section(section)) - } - - static func page(_ page: VaultPage, count: Int, selected: Bool) -> Self { - .init(id: .page(page), content: .page(page, count: count, selected: selected)) - } - - static func recent( - _ host: Host, - address: String, - status: ConnectionStatus - ) -> Self { - .init( - id: .recent(host.id), - content: .recent(host, address: address, status: status) - ) - } - - var height: CGFloat { - switch content { - case .section: 34 - case .page: Metrics.Sidebar.navigationRowHeight - case .recent: - Metrics.Sidebar.navigationRowHeight - } - } -} - -private struct VaultNavigationNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (VaultNavigationItem) -> AnyView - - let items: [VaultNavigationItem] - let renderer: Renderer - - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [VaultNavigationItem], - @ViewBuilder renderer: @escaping @MainActor (VaultNavigationItem) -> some View - ) { - self.items = items - self.renderer = { AnyView(renderer($0)) } - } - - func makeCoordinator() -> Coordinator { Coordinator() } - - func makeNSView(context: Context) -> NSViewType { - context.coordinator.makeListView() - } - - func updateNSView(_ listView: NSViewType, context: Context) { - context.coordinator.update( - listView: listView, - items: items, - renderer: renderer, - locale: locale, - colorScheme: colorScheme - ) - } - - @MainActor - final class Coordinator { - private var renderer: Renderer? - private var locale = Locale.current - private var colorScheme = ColorScheme.light - private var content: [VaultNavigationItem] = [] - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.rows { - ListRow(VaultNavigationHostingRow.self) - .height { item, _ in item.height } - .configure { [weak self] row, item, _ in - self?.configure(row, item: item) - } - } - return listView - } - - func update( - listView: NSViewType, - items: [VaultNavigationItem], - renderer: @escaping Renderer, - locale: Locale, - colorScheme: ColorScheme - ) { - self.renderer = renderer - let environmentChanged = self.locale != locale || self.colorScheme != colorScheme - self.locale = locale - self.colorScheme = colorScheme - if content.map(\.id) == items.map(\.id) { - for (old, new) in zip(content, items) where environmentChanged || old != new { - listView.update(new) - } - } else { - listView.apply(items, animated: !content.isEmpty) - } - content = items - } - - private func configure(_ row: VaultNavigationHostingRow, item: VaultNavigationItem) { - guard let renderer else { return } - let content = renderer(item) - .id(item.id) - .environment(\.locale, locale) - .environment(\.colorScheme, colorScheme) - row.configure(rootView: AnyView(content)) - } - } -} - -@MainActor -private final class VaultNavigationHostingRow: ListRowView { - private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) - - override init(frame: CGRect) { - super.init(frame: frame) - hostingView.translatesAutoresizingMaskIntoConstraints = false - addSubview(hostingView) - NSLayoutConstraint.activate([ - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - override func prepareForReuse() { - super.prepareForReuse() - hostingView.rootView = AnyView(EmptyView()) - } - - func configure(rootView: AnyView) { - hostingView.rootView = rootView - } -} - /// The first pinned tab's main content. Its navigator is mounted by the /// window-level split view and remains alive while top-level tabs change. struct VaultView: View { diff --git a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift index b3cc281d..020caf9e 100644 --- a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift +++ b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift @@ -124,11 +124,6 @@ final class SwiftDataHostRepository: HostRepository { try commitMutation() } - /// Called by repository mutations after their database transaction commits. - func publishMutation() throws { - try publishMutation(reason: .mutation) - } - private func publishMutation(reason: RepositoryChangeReason) throws { let revision = try advanceRevision() let current = try snapshot() diff --git a/Termind/Infrastructure/LLM/AgentProviderClient.swift b/Termind/Infrastructure/LLM/AgentProviderClient.swift index abded8d5..d7fac365 100644 --- a/Termind/Infrastructure/LLM/AgentProviderClient.swift +++ b/Termind/Infrastructure/LLM/AgentProviderClient.swift @@ -1293,15 +1293,6 @@ nonisolated private extension Array where Element == AgentModelStreamEvent { } } } - - var containsText: Bool { - contains { - switch $0 { - case .textDelta, .textSnapshot: true - default: false - } - } - } } nonisolated enum AgentProviderError: LocalizedError, Sendable { diff --git a/Termind/Infrastructure/LLM/GoogleServiceAccountAuth.swift b/Termind/Infrastructure/LLM/GoogleServiceAccountAuth.swift index 38864b81..5f59a28c 100644 --- a/Termind/Infrastructure/LLM/GoogleServiceAccountAuth.swift +++ b/Termind/Infrastructure/LLM/GoogleServiceAccountAuth.swift @@ -65,19 +65,6 @@ actor GoogleServiceAccountAuth { return token } - /// What the editor's Test button runs. - /// - /// Returns the account it signed in as. A full round trip through the token - /// endpoint is the right check for Vertex precisely because it does not - /// spend a model token: the failures that actually happen here are a - /// malformed JSON, a project the account cannot see, or a missing IAM role, - /// and all three surface at this exchange. - func verify(serviceAccountJSON: String) async throws -> String { - let credentials = try ServiceAccount(json: serviceAccountJSON) - _ = try await accessToken(serviceAccountJSON: serviceAccountJSON) - return credentials.clientEmail - } - /// Reads `client_email` out of a pasted JSON without signing anything, so /// the editor can label the field the moment a file is imported. static func clientEmail(in serviceAccountJSON: String) -> String? { @@ -88,11 +75,6 @@ actor GoogleServiceAccountAuth { (try? ServiceAccount(json: serviceAccountJSON))?.projectID } - /// Drops cached tokens for an account whose key was replaced. - func forget(clientEmail: String) { - cache[clientEmail] = nil - } - // MARK: - Token exchange private func exchange(_ assertion: String, at tokenURI: String) async throws -> ( diff --git a/Termind/Infrastructure/SSH/SFTPClient.swift b/Termind/Infrastructure/SSH/SFTPClient.swift index 29b57e29..3cbbcd08 100644 --- a/Termind/Infrastructure/SSH/SFTPClient.swift +++ b/Termind/Infrastructure/SSH/SFTPClient.swift @@ -27,7 +27,6 @@ nonisolated struct RemoteFile: Identifiable, Hashable, Sendable { var id: String { path } var isDirectory: Bool { kind == .directory } - var isSymbolicLink: Bool { kind == .symbolicLink } var isHidden: Bool { name.hasPrefix(".") } var permissionMode: UInt32 { permissions & 0o7777 } diff --git a/Termind/Infrastructure/SSH/SSHExecSession.swift b/Termind/Infrastructure/SSH/SSHExecSession.swift index a6a6c512..ad38807c 100644 --- a/Termind/Infrastructure/SSH/SSHExecSession.swift +++ b/Termind/Infrastructure/SSH/SSHExecSession.swift @@ -54,20 +54,6 @@ nonisolated protocol SSHExecSessionProtocol: AnyObject, Sendable { /// shape. typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void -nonisolated extension SSHExecSessionProtocol { - /// For the callers that only want the finished result — which is most of - /// them, and all of the ones that predate streaming. - func execute( - _ command: String, workingDirectory: String?, stdin: Data?, - timeout: TimeInterval, outputLimit: Int - ) async throws -> SSHExecResult { - try await execute( - command, workingDirectory: workingDirectory, stdin: stdin, - timeout: timeout, outputLimit: outputLimit, onOutput: nil - ) - } -} - actor SSHExecSession: SSHExecSessionProtocol { private nonisolated let worker = SSHExecWorker() diff --git a/Termind/Infrastructure/SSH/SSHSessionActor.swift b/Termind/Infrastructure/SSH/SSHSessionActor.swift index 0a591c59..78d8f128 100644 --- a/Termind/Infrastructure/SSH/SSHSessionActor.swift +++ b/Termind/Infrastructure/SSH/SSHSessionActor.swift @@ -129,15 +129,6 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable { } } - /// Explicit cancellation is kept for connection teardown and focused unit - /// tests. Production timeout handling uses `poll` so late output remains - /// eligible for filtering. - mutating func cancel() -> Data { - let visible = cleanBufferedOutput() - finish() - return visible - } - private mutating func receiveProbe(now: UInt64, isLate: Bool) -> Update { let prefix = Data("__TRMD_\(token)_SHELL__".utf8) guard let prefixRange = buffer.range(of: prefix) else { return Update() } @@ -1187,83 +1178,6 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { return "\(prefix): \(detail)" } - private static func openSocket(host: String, port: Int, timeout: TimeInterval) throws -> Int32 { - var hints = addrinfo() - hints.ai_family = AF_UNSPEC - hints.ai_socktype = SOCK_STREAM - hints.ai_protocol = IPPROTO_TCP - var result: UnsafeMutablePointer? - let status = getaddrinfo(host, String(port), &hints, &result) - guard status == 0, let first = result else { - throw TermindSSHError.socket(String(cString: gai_strerror(status))) - } - defer { freeaddrinfo(first) } - - var cursor: UnsafeMutablePointer? = first - var lastError = String(localized: "Connection failed") - while let info = cursor?.pointee { - let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) - if candidate >= 0 { - var yes: Int32 = 1 - setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size(ofValue: yes))) - var interval = timeval( - tv_sec: Int(timeout), - tv_usec: Int32((timeout - floor(timeout)) * 1_000_000) - ) - setsockopt(candidate, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) - setsockopt(candidate, SOL_SOCKET, SO_SNDTIMEO, &interval, socklen_t(MemoryLayout.size)) - do { - try connectSocket( - candidate, address: info.ai_addr, - addressLength: info.ai_addrlen, timeout: timeout - ) - return candidate - } catch { - lastError = error.localizedDescription - } - Darwin.close(candidate) - } - cursor = info.ai_next - } - throw TermindSSHError.socket(lastError) - } - - private static func connectSocket( - _ socket: Int32, address: UnsafePointer, - addressLength: socklen_t, timeout: TimeInterval - ) throws { - let flags = fcntl(socket, F_GETFL, 0) - guard flags >= 0, fcntl(socket, F_SETFL, flags | O_NONBLOCK) == 0 else { - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - defer { _ = fcntl(socket, F_SETFL, flags) } - - if Darwin.connect(socket, address, addressLength) == 0 { return } - guard errno == EINPROGRESS else { - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - - var descriptor = pollfd(fd: socket, events: Int16(POLLOUT), revents: 0) - let milliseconds = Int32( - min(Double(Int32.max), max(1, timeout * 1_000)) - ) - let pollResult = Darwin.poll(&descriptor, 1, milliseconds) - if pollResult == 0 { - throw TermindSSHError.socket(String(localized: "Connection timed out.")) - } - guard pollResult > 0 else { - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - - var socketError: Int32 = 0 - var errorLength = socklen_t(MemoryLayout.size) - guard getsockopt(socket, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) == 0 else { - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - guard socketError == 0 else { - throw TermindSSHError.socket(String(cString: strerror(socketError))) - } - } } private nonisolated final class SecureEnclaveSigner: @unchecked Sendable { diff --git a/Termind/Infrastructure/Security/KeychainSecretStore.swift b/Termind/Infrastructure/Security/KeychainSecretStore.swift index e97c2c11..153fefe1 100644 --- a/Termind/Infrastructure/Security/KeychainSecretStore.swift +++ b/Termind/Infrastructure/Security/KeychainSecretStore.swift @@ -35,11 +35,6 @@ nonisolated struct KeychainSecretStore: SecretStoring, Sendable { self.accessGroup = accessGroup } - /// Local-only compatibility entry point used by AI and MCP secrets. - func string(for account: String) throws -> String? { - try string(for: account, storage: .thisDevice) - } - func string(for account: String, storage: PasswordStorage) throws -> String? { try validateStringReference(account) guard @@ -54,11 +49,6 @@ nonisolated struct KeychainSecretStore: SecretStoring, Sendable { return value } - /// Local-only compatibility entry point used by AI and MCP secrets. - func setString(_ value: String?, for account: String) throws { - try setString(value, for: account, storage: .thisDevice) - } - func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { try validateStringReference(account) try setData( diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index dbd3f541..1566a093 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -21,26 +21,6 @@ } } }, - "Hide Session Details" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐藏会话详情" - } - } - } - }, - "Show Session Details" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "显示会话详情" - } - } - } - }, "[… %lld characters omitted …]" : { "localizations" : { "zh-Hans" : { @@ -5129,6 +5109,16 @@ } } }, + "Hide Session Details" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏会话详情" + } + } + } + }, "Hide tag suggestions" : { "localizations" : { "zh-Hans" : { @@ -8026,6 +8016,7 @@ } }, "Private keys and passwords stay in the Keychain. Termind stores only labels, fingerprints, and references." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10031,6 +10022,16 @@ } } }, + "Show Session Details" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示会话详情" + } + } + } + }, "Show tag suggestions" : { "localizations" : { "zh-Hans" : { @@ -13645,4 +13646,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/Termind/UI/Components/ManagedChooser.swift b/Termind/UI/Components/ManagedChooser.swift index a7689e9b..d0c85f92 100644 --- a/Termind/UI/Components/ManagedChooser.swift +++ b/Termind/UI/Components/ManagedChooser.swift @@ -5,23 +5,8 @@ enum ManagedChooserMode: Sendable { case multiple } -/// Testable selection and keyboard policy shared by every searchable chooser. -protocol ManagedChooser: AnyObject { - associatedtype ID: Hashable - - var mode: ManagedChooserMode { get } - var query: String { get set } - var cursor: ID? { get set } - var selected: Set { get set } - - func activate(_ id: ID) - func moveCursor(by delta: Int, through ids: [ID]) - func queryDidChange(visible ids: [ID], enabled: Set) - func toggleAll(visible ids: [ID], enabled: Set) -} - @Observable -final class ManagedChooserState: ManagedChooser { +final class ManagedChooserState { let mode: ManagedChooserMode var query = "" var cursor: ID? @@ -78,19 +63,6 @@ final class ManagedChooserState: ManagedChooser { } } - func pruneSelection(available: Set) { - selected.formIntersection(available) - if let cursor, !available.contains(cursor) { self.cursor = nil } - } - - func confirmedIDs(in order: [ID]) -> [ID] { - switch mode { - case .single: - return cursor.map { [$0] } ?? [] - case .multiple: - return order.filter(selected.contains) - } - } } struct SelectionMark: View { diff --git a/Termind/UI/Components/SharedChrome.swift b/Termind/UI/Components/SharedChrome.swift index a549764d..53e51e4a 100644 --- a/Termind/UI/Components/SharedChrome.swift +++ b/Termind/UI/Components/SharedChrome.swift @@ -241,15 +241,3 @@ struct SelectableRow: View { return .clear } } - -extension SelectableRow where Trailing == EmptyView { - init( - isCursor: Bool, isSelected: Bool, isEnabled: Bool = true, - @ViewBuilder content: () -> Content - ) { - self.init( - isCursor: isCursor, isSelected: isSelected, isEnabled: isEnabled, - content: content, trailing: { EmptyView() } - ) - } -} diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index c526e0c9..5e342a01 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -16,23 +16,6 @@ nonisolated enum WindowToolbarFindKind: String, Hashable, Sendable, CaseIterable case terminal case sftp - /// The tooltip. The field itself always reads "Search" — one placeholder for - /// one control — so this is where the scope is actually named. - var help: String { - switch self { - case .hosts: String(localized: "Search Hosts") - case .keychain: String(localized: "Search Keychain") - case .snippets: String(localized: "Search Snippets") - case .assistant: String(localized: "Search Conversations") - case .terminal: String(localized: "Find in Terminal") - case .sftp: String(localized: "Find in SFTP") - } - } - - /// Whether Return and Shift-Return walk between matches. Only the terminal - /// has matches to walk — everything else filters a list in place, where - /// Return would have nothing to move to. - var navigatesMatches: Bool { self == .terminal } } /// Everything the titlebar Find field needs, resolved once per body from the diff --git a/Termind/UI/Managed/CardGrid.swift b/Termind/UI/Managed/CardGrid.swift index 4d7e8ef9..882c52ea 100644 --- a/Termind/UI/Managed/CardGrid.swift +++ b/Termind/UI/Managed/CardGrid.swift @@ -364,24 +364,6 @@ struct CardPage: View { } #endif -/// A titled group of cards. The count sits in the header because a section that -/// scrolled off the bottom is otherwise indistinguishable from an empty one. -struct CardSection: View { - /// A plain `String`, not a `LocalizedStringKey`: half these headers are group - /// names the user typed, and running those through the catalogue would be - /// wrong. Static headers pass `String(localized:)`, which still extracts. - let title: String - var count: Int? - @ViewBuilder var content: Content - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.Space.l) { - CardSectionHeader(title: title, count: count) - CardGrid { content } - } - } -} - /// The title row a section leads with, on its own so a section whose header does /// something — a group, which can be collapsed, selected, renamed, deleted — can /// reuse it instead of drawing a second one that has to be kept looking alike. diff --git a/Termind/UI/Managed/ManagedCardPage.swift b/Termind/UI/Managed/ManagedCardPage.swift index dbf7ebf1..ac457560 100644 --- a/Termind/UI/Managed/ManagedCardPage.swift +++ b/Termind/UI/Managed/ManagedCardPage.swift @@ -67,10 +67,6 @@ struct ManagedCardItemContext { /// the inherited body after the shared page has been rendered. protocol ManagedCardPresentationModifier: ViewModifier {} -struct EmptyManagedCardPresentation: ManagedCardPresentationModifier { - func body(content: Content) -> some View { content } -} - /// SwiftUI's protocol-oriented equivalent of a page base class. Conformers /// provide data and business views; this protocol extension owns the body. protocol ManagedCardPage: View { diff --git a/Termind/UI/Managed/PageFootnote.swift b/Termind/UI/Managed/PageFootnote.swift deleted file mode 100644 index 4957c9ae..00000000 --- a/Termind/UI/Managed/PageFootnote.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -/// The line of small print under a manager page's table. Used for the things a -/// user needs told once — where private keys actually live, when a tunnel is -/// open — rather than in a settings pane they will never open. -struct PageFootnote: View { - let text: LocalizedStringKey - @ViewBuilder var actions: Actions - - var body: some View { - HStack(spacing: Metrics.Space.m) { - Text(text) - .font(.caption) - .foregroundStyle(.secondary) - Spacer(minLength: Metrics.Space.m) - actions - } - .controlSize(.small) - .padding(.horizontal, Metrics.Space.l) - .padding(.vertical, Metrics.Space.m) - .termindGlassBar() - } -} diff --git a/TermindTests/AgentToolSchedulerTests.swift b/TermindTests/AgentToolSchedulerTests.swift index 80255e0d..f6a041d8 100644 --- a/TermindTests/AgentToolSchedulerTests.swift +++ b/TermindTests/AgentToolSchedulerTests.swift @@ -46,11 +46,6 @@ private actor ScriptedTurns: AgentModelStreaming { } } -private actor EventLog { - private(set) var events: [AgentEvent] = [] - func append(_ event: AgentEvent) { events.append(event) } -} - /// One turn asking for `names`, then a plain answer. private func callingTurns(_ names: [String]) -> [[AgentModelStreamEvent]] { [