chore: remove unused code
This commit is contained in:
1 parent
6380c7c00a
commit
d669c2efac
55 files changed
+30
-1657
No files matched your search
@@ -0,0 +1,5 @@
|
||||
project: Termind.xcodeproj
|
||||
retain_public: true
|
||||
schemes:
|
||||
- Termind
|
||||
- TermindMCPBridge
|
||||
@@ -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)
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]] {
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user