chore: remove unused code

This commit is contained in:
Kaede Akino committed 2026-08-29 09:03:24 +08:00
1 parent 6380c7c00a
commit d669c2efac
55 files changed
+30 -1657

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
project: Termind.xcodeproj
retain_public: true
schemes:
- Termind
- TermindMCPBridge
-4
View File
@@ -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)
-1
View File
@@ -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.
@@ -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] {
@@ -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 {
@@ -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)
@@ -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
@@ -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 {
@@ -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] {
+1 -8
View File
@@ -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 {
@@ -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
@@ -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)
}
@@ -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)
}
}
@@ -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.
@@ -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)
}
@@ -8,6 +8,4 @@ final class TransferLibraryModel {
init(library: HostLibraryModel) {
self.library = library
}
func host(_ id: Host.ID) -> Host? { library.host(id) }
}
-42
View File
@@ -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))
}
-11
View File
@@ -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.
-10
View File
@@ -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
@@ -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,
-7
View File
@@ -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.
@@ -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
-13
View File
@@ -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)
}
}
@@ -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<Host.ID> {
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)
}
}
@@ -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)
@@ -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
@@ -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 {
@@ -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<ContextPanelDetailID> { 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<ContextPanelDetailID>
private let resolve: (ContextPanelDetailID) -> ContextPanelDetailPresentation?
init<P: ContextPanelDetailProvider>(_ provider: P) {
kind = provider.kind
page = provider.page
emptyState = provider.emptyState
available = { provider.availableIDs }
resolve = provider.presentation
}
var availableIDs: Set<ContextPanelDetailID> { 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<ContextPanelDetailID> { 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<ContextPanelDetailID> { 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<ContextPanelDetailID> {
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<ContextPanelDetailID> {
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<Icon: View, Title: View>: 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)
}
}
@@ -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<ContextPanelDetailID> = []
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<Bool> {
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<Content: View>: 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)
}
}
}
@@ -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<Host.ID>
@State private var connectionProtocol = HostConnectionProtocol.ssh
@State private var isConfirmingDestructiveSnippet = false
@@ -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<Host.ID>) {
let valid = ids.intersection(Set(app.hostLibrary.hosts.map(\.id)))
state.selectedGroupID = nil
@@ -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)
@@ -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
@@ -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] = []
@@ -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<Snippet.ID>) {
let valid = ids.intersection(Set(app.snippets.snippets.map(\.id)))
state.selectedPackageID = nil
-71
View File
@@ -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 }
}
-6
View File
@@ -158,12 +158,6 @@ final class WindowSidebarState {
detailOverridePaneID = nil
}
func validActiveDetails(for pane: Pane?, available: Set<ContextPanelDetailID>)
-> [ContextPanelDetailID]
{
activeDetails(for: pane).filter(available.contains)
}
func pruneSelections(available: Set<ContextPanelDetailID>) {
for page in VaultPage.allCases {
let valid = selection(for: page).filter(available.contains)
-7
View File
@@ -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
@@ -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(
-285
View File
@@ -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<VaultNavigationItem>
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 {
@@ -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()
@@ -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 {
@@ -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 -> (
@@ -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 }
@@ -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()
@@ -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<addrinfo>?
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<addrinfo>? = 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<timeval>.size))
setsockopt(candidate, SOL_SOCKET, SO_SNDTIMEO, &interval, socklen_t(MemoryLayout<timeval>.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<sockaddr>,
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<Int32>.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 {
@@ -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(
+22 -21
View File
@@ -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"
}
}
+1 -29
View File
@@ -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<ID> { get set }
func activate(_ id: ID)
func moveCursor(by delta: Int, through ids: [ID])
func queryDidChange(visible ids: [ID], enabled: Set<ID>)
func toggleAll(visible ids: [ID], enabled: Set<ID>)
}
@Observable
final class ManagedChooserState<ID: Hashable>: ManagedChooser {
final class ManagedChooserState<ID: Hashable> {
let mode: ManagedChooserMode
var query = ""
var cursor: ID?
@@ -78,19 +63,6 @@ final class ManagedChooserState<ID: Hashable>: ManagedChooser {
}
}
func pruneSelection(available: Set<ID>) {
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 {
-12
View File
@@ -241,15 +241,3 @@ struct SelectableRow<Content: View, Trailing: View>: 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() }
)
}
}
-17
View File
@@ -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
-18
View File
@@ -364,24 +364,6 @@ struct CardPage<Content: View>: 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<Content: View>: 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.
-4
View File
@@ -67,10 +67,6 @@ struct ManagedCardItemContext<Item: Identifiable> {
/// 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 {
-23
View File
@@ -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<Actions: View>: 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()
}
}
@@ -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]] {
[