diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index c53144eb..2e807c05 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -363,3 +363,148 @@ Reference scanning confirmed that the following declarations had no callers: - The targeted verification batch passed: `ConnectionJourneyTests`, `SFTPFeatureTests`, `SFTPSessionTests`, `QuickOpenTests`, `WindowSidebarTests`, `RepositoryOutboxTests`, `SnippetFeatureTests`, and `PortForwardingFeatureTests`. Earlier targeted batches for tool presentation, histories, idle sweeping, provider networking, compaction, and Transcript/Composer behavior also passed. - Compiling the test target exposed two stale Swift 6 issues in `ExternalRequestTests`: missing required `startedAt` arguments and mutation of an actor-isolated captured Boolean from a Sendable observation callback. Fixed timestamps and a nonisolated reference flag now keep those serialized tests deterministic and compilable. - Items 1–25 are implemented at their documented safe boundaries. Future entries should be appended for newly discovered duplication instead of broadening completed items into callback-heavy abstractions. + +## Deep Scan III — 2026-08-30 + +This pass rescanned all 252 production Swift files after Items 1–25 were completed. It compared exact and identifier-normalized code fragments, checked low-reference declarations against both `Termind` and `TermindTests`, and then read each candidate in context. The findings below are new work; none requires reviving an abstraction rejected by an earlier item. + +### 26. Delete the byte-identical `SettingsCollectionPage` wrapper — Highest priority + +**Status:** Completed on 2026-08-30; build and focused Settings tests passed. + +**Evidence:** + +- `SettingsFormPage` and `SettingsCollectionPage` in `Termind/Features/Settings/SettingsPageView.swift` have identical stored properties and identical bodies: header, divider, grouped form, content-width constraint, and outer expansion. +- The only distinction is the type name. Provider and MCP routes construct `SettingsCollectionPage`; every other route constructs `SettingsFormPage`. There is no behavior or styling attached to the distinction. + +**Recommended action:** Replace the two `SettingsCollectionPage` construction sites with `SettingsFormPage`, then delete `SettingsCollectionPage`. Do not introduce a renamed third wrapper. + +**Estimated net reduction:** Approximately 15–17 lines with no new code. + +### 27. Merge the three fixed-height cursor-scrolling hosting lists — High priority; follow-up to Item 2 + +**Status:** Completed on 2026-08-30; build and focused ListView/Chooser tests passed. + +**Evidence:** + +- `SnippetTargetNativeList`, `HostChooserNativeList`, and `ExportHostNativeList` are still nearly byte-identical after the general ListView migration. +- All three use a 52-point `HostingListRow`, `ListBouncyAnimator`, `HostingListContent`, animated updates, and `items.first(where: \.isCursor)` followed by the same non-animated nearest-row scroll. +- Their item types all satisfy the same real contract: `Identifiable & Hashable & SendableMetatype` plus a Boolean cursor property. Only the item and renderer types differ. + +**Recommended action:** Add one narrowly named fixed-height cursor hosting adapter in `UI/Components`, backed by a tiny item protocol exposing `isCursor`. Replace only these three adapters. Keep dynamically sized lists, drag/drop lists, Assistant conversation rows, and Transcript rows out of it. + +**Estimated net reduction:** Approximately 80–110 lines after the shared adapter is counted. + +### 28. Share the Agent Exec/SFTP authentication retry loop — High priority + +**Status:** Completed on 2026-08-30; build and all 21 Agent Host Authentication tests passed. + +**Evidence:** + +- `AgentSSHAuthenticationRegistry.openExec` and the loop inside `sftp` in `Termind/Application/Agent/AgentConnections.swift` repeat the same attempt-generation selection and four error branches. +- Both retry authentication, obtain a new configuration attempt, retry untrusted/changed host keys, and disconnect the matching pooled connection before retrying remote authentication or host-key failures. +- The meaningful differences are only the pool kind and the operation used to open the connection. + +**Recommended action:** Extract a private generic `openAuthenticated` loop that receives the connection kind and one async open closure. Keep Exec and SFTP return types explicit at their public entry points. Do not move pool implementation or authentication-session state into the helper. + +**Estimated net reduction:** Approximately 35–50 lines. + +### 29. Remove the second Native Transcript risk-color and destructive-confirmation implementation — High priority + +**Status:** Completed on 2026-08-30; build passed. + +**Evidence:** + +- `TranscriptNativeRows.swift` defines both `CommandNativeRow.riskColor` and file-private `nativeRiskColor`; their switches are identical. +- The same file defines both `CommandNativeRow.confirmDestructiveCommand` and `presentDestructiveCommandConfirmation`; the complete `NSAlert` construction and sheet/modal fallback are byte-identical. +- Each pair exists only because two native row implementations call different names. No type-specific state is used by either helper. + +**Recommended action:** Keep one file-private risk-color function and one file-private confirmation function and route both row types through them. Prefer the file-private location because neither operation belongs to one row class. + +**Estimated net reduction:** Approximately 25–35 lines. + +### 30. Replace three copied Glass shape modifiers with one shape-parameterized modifier — Medium-high priority + +**Status:** Completed on 2026-08-30; build passed. + +**Evidence:** + +- `TermindGlassSurface`, `TermindGlassCapsule`, and `TermindGlassCircle` in `Termind/Design/DesignTokens.swift` repeat the macOS 26 availability branch, regular Glass configuration, material fallback, optional tint fill, border, and hit-testing behavior. +- Their only semantic difference is the shape: rounded rectangle, capsule, or circle. The public `termindGlassSurface`, `termindGlassCapsule`, and `termindGlassCircle` methods can remain unchanged. + +**Recommended action:** Use one private generic modifier parameterized by an insettable shape and have the three public view extensions construct it with the appropriate shape. Preserve the existing API and macOS 15 fallback exactly; do not type-erase the whole view tree. + +**Estimated net reduction:** Approximately 35–45 lines. + +### 31. Consolidate local Vault envelope insertion/update — Medium priority + +**Status:** Completed on 2026-08-30; build and all focused Vault Sync tests passed. + +**Evidence:** + +- `VaultSyncPersistence.recordLocalChanges` repeats the same `if let row { apply } else { create, insert, cache }` block for payload changes and tombstones. +- `recordCurrentSnapshot` repeats that block again for current payloads, while `acceptRemote` contains the same insertion/update shape with `needsUpload: false`. +- Envelope construction and the rules deciding which records changed are meaningful and different; only persistence of an already-constructed envelope is duplicated. + +**Recommended action:** Add a private helper that stores one `VaultEnvelope`, accepts `needsUpload`, and updates the in-memory `entries` index. Leave version creation, tombstone detection, `changed` tracking, and remote merge policy in their current callers. + +**Estimated net reduction:** Approximately 20–30 lines. + +### 32. Share remaining SFTP remote-conflict preflight — Medium-high priority; distinct from Item 17 + +**Status:** Completed on 2026-08-30; build and focused SFTP Feature/Session tests passed. + +**Evidence:** + +- `SFTPSessionModel.prepareUpload` and `prepareRemoteCopy` still independently sort remote directories by path length, skip descendants of an already-conflicting directory, query remote attributes, count conflicts, and then query every remaining destination path. +- Item 17 unified conflict-policy resolution after a batch exists. This duplication occurs earlier, while the two batch types are being prepared, so it was not removed by that work. +- `PreparedJob` and `PreparedRemoteCopyJob` should remain separate; the reusable result is only the set of conflicting directories and the attributes found for destination paths. + +**Recommended action:** Extract a private async preflight that accepts remote directories and destination paths and returns ordered directories, conflicting directory paths, and conflicting target kinds. Each caller should map that result back onto its own job type. Do not create a universal transfer job with optional local/source fields. + +**Estimated net reduction:** Approximately 25–40 lines. + +### 33. Centralize the effective Vertex mode and credential reference on `ModelProvider` — Medium priority + +**Status:** Completed on 2026-08-30; build and focused Provider tests passed. + +**Evidence:** + +- The expression `usesVertex && kind == .google` is repeated across `ModelProvider`, `ModelCatalogClient`, `ModelListEditor`, `ModelProviderEditorSheet`, `CommandGeneratorService`, `ConversationTitleService`, and `AppModel`. +- Six model-construction paths independently select `vertex.credentialRef` versus `credentialRef` with the same ternary expression. +- `ModelProvider` already owns related derived behavior such as `hasCredential`, `resolvedRequestURL`, and `resolvedModelsURL`; callers should not re-derive which authentication mode is active. + +**Recommended action:** Add concise derived properties such as `usesVertexEndpoint` and `effectiveCredentialRef` to `ModelProvider`, then replace external branch copies. Keep `hasCredential` stricter than a nonempty reference because Vertex also requires project configuration. + +**Estimated net reduction:** Approximately 15–25 lines while removing a repeated provider rule. + +### 34. Delete newly confirmed uncalled declarations and unused decoded metadata — High priority; small batch + +**Status:** Completed on 2026-08-30; build passed. + +Reference scanning across production and test Swift found no caller for: + +- `OnboardingController.isPresenting`. +- `AgentBuiltInTools.hostPreflight` in `AgentBuiltInSFTPTools.swift`. +- `TermindColor.disabledForeground`, `nativeSuccess`, `nativeWarning`, and `nativeDanger`. +- `TrialEligibilityStore.expireForTesting`; the Debug controller exposes only `resetForTesting`. +- Runtime reads of `LinuxDistributionIconEntry.slug`, `sourceURL`, and `license`, and of `LinuxDistributionIconCatalog.source`, `package`, `version`, and `integrity`. The icon lookup uses only `title`, `asset`, `ids`, and `hex`. + +**Recommended action:** Delete the declarations after one final `rg` check. Remove the unused Linux metadata fields only from the runtime Codable structs; keep the metadata in the generated JSON and update script as supply-chain/license provenance. Swift's decoder ignores those extra JSON keys. + +**Estimated net reduction:** Approximately 35–45 lines with no replacement abstraction. + +## Deep Scan III — Items Deliberately Not Added + +- The duplicated V7/V8 SwiftData Host models in `HostPersistence.swift` are migration schemas, not ordinary runtime duplication. Merging them would invalidate stored-model contracts. +- The dedicated Assistant conversation hosting row remains intentionally separate from `HostingListRow`: it clears itself when detached from the hierarchy and carries drag/drop identity state. +- SSH and SFTP session models still share visible authentication shapes, but their credential persistence, transient-host handling, transport startup, and failure recovery differ. No further whole-session abstraction is recommended beyond the small connection-controller boundary already completed in Item 7. +- The in-memory Command, Connection, and SFTP history repositories are structurally similar, but a generic repository would introduce type-erased predicates or policy closures for little net reduction. Their shared SwiftData transaction mechanics were already consolidated in Item 10. + +## Deep Scan III Implementation Verification — 2026-08-30 + +- Items 26–34 add 235 and delete 508 production Swift lines: **net −273 lines**. Documentation changes are excluded from this count. +- One Debug macOS build completed successfully after all nine items were implemented. +- The focused batch ran 114 tests across Vault Sync, SFTP Feature/Session, Agent Connection Pool, ListView migration, Managed Chooser, AI Provider persistence, Settings navigation, and the color system. 113 passed. The sole failure is an unrelated pre-existing static-scan false positive: `ColorSystemTests.featureUIHasNoLiteralDisplayColorsOrDecorativeBlue` interprets the domain value `colorTag: .blue` in unchanged `HostLibraryModel+Demo.swift` as a display color. +- A separate final run of all 21 `AgentHostAuthenticationTests` passed, covering shared prompts, rejected credentials, stale generations, host-key retries, Exec/SFTP separation, and scratch SFTP reuse through the consolidated retry loop. +- `git diff --check` passed, and final reference scans found no remaining use of the deleted declarations or duplicate wrapper/helper names. diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index e08e099c..ceb144ce 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -341,10 +341,7 @@ final class AppModel { )) return } - let credentialRef = - chat.provider.usesVertex && chat.provider.kind == .google - ? chat.provider.vertex.credentialRef : chat.provider.credentialRef - guard let secret = ai.secret(for: credentialRef), !secret.isEmpty else { + guard let secret = ai.secret(for: chat.provider.effectiveCredentialRef), !secret.isEmpty else { conversation.messages.append( ChatMessage( role: .assistant, text: String(localized: "The Chat model has no available credential.") @@ -361,10 +358,9 @@ final class AppModel { ) let reviewer: (any SecurityReviewing)? = { guard mode == .approveForMe, let review = ai.resolve(.securityReview) else { return nil } - let ref = - review.provider.usesVertex && review.provider.kind == .google - ? review.provider.vertex.credentialRef : review.provider.credentialRef - guard let reviewSecret = ai.secret(for: ref), !reviewSecret.isEmpty else { return nil } + guard let reviewSecret = ai.secret(for: review.provider.effectiveCredentialRef), + !reviewSecret.isEmpty + else { return nil } let reasoning = SecurityReviewClient.preferredReasoning( model: review.model, provider: review.provider ) @@ -545,10 +541,8 @@ final class AppModel { /// expensive. private func compactionModel() -> (any AgentModelCompleting)? { guard let resolved = ai.resolve(.chat) else { return nil } - let credentialRef = - resolved.provider.usesVertex && resolved.provider.kind == .google - ? resolved.provider.vertex.credentialRef : resolved.provider.credentialRef - guard let secret = ai.secret(for: credentialRef), !secret.isEmpty else { return nil } + guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty + else { return nil } let reasoning = ModelCapabilityResolver.reasoning( model: resolved.model, provider: resolved.provider ).clamp(.off) diff --git a/Termind/App/OnboardingController.swift b/Termind/App/OnboardingController.swift index 6c90bdd2..512e618a 100644 --- a/Termind/App/OnboardingController.swift +++ b/Termind/App/OnboardingController.swift @@ -143,10 +143,6 @@ final class OnboardingController { } } - func isPresenting(_ step: OnboardingStep, in window: WindowModel) -> Bool { - presentation?.step == step && presentation?.windowID == ObjectIdentifier(window) - } - func complete(_ step: OnboardingStep) { if presentation?.step == step, presentation?.mode == .preview { finishPreview() diff --git a/Termind/Application/Agent/AgentBuiltInSFTPTools.swift b/Termind/Application/Agent/AgentBuiltInSFTPTools.swift index 7a8c3e13..6c78ee5b 100644 --- a/Termind/Application/Agent/AgentBuiltInSFTPTools.swift +++ b/Termind/Application/Agent/AgentBuiltInSFTPTools.swift @@ -438,18 +438,6 @@ nonisolated extension AgentBuiltInTools { } } - /// Resolves `host_id` before approval so the tool card names the host the - /// action is aimed at rather than an opaque argument blob. - private static func hostPreflight( - safety: AgentToolDescriptor.Safety - ) -> @Sendable (AgentToolInvocation) async throws -> AgentToolPreflight { - { invocation in - var checked = invocation - checked.targetHostID = try Arguments(invocation).uuid("host_id") - return AgentToolPreflight(invocation: checked, safety: safety) - } - } - /// Internal rather than private because `scratch_pull` and `scratch_push` aim at /// the same remote paths the SFTP tools do, and two implementations of "what is /// a valid remote path" would be one too many. diff --git a/Termind/Application/Agent/AgentConnections.swift b/Termind/Application/Agent/AgentConnections.swift index e3ca681b..a6e1ce40 100644 --- a/Termind/Application/Agent/AgentConnections.swift +++ b/Termind/Application/Agent/AgentConnections.swift @@ -654,41 +654,13 @@ actor AgentSSHAuthenticationRegistry { hostID: Host.ID, authentication: AgentSSHConfigurationSession, pool: AgentConnectionPool ) async throws -> any SSHExecSessionProtocol { - var override: AgentSSHConfigurationSession.Attempt? - while true { - let attemptedGeneration: UInt64 - if let override { - attemptedGeneration = override.generation - } else { - attemptedGeneration = await authentication.currentGeneration - } - do { - if let override { - return try await pool.exec(hostID: hostID) { _ in - override.configuration - } - } - return try await pool.exec(hostID: hostID) - } catch let error as TermindSSHError where error.shouldRequestAuthentication { - await pool.disconnect(hostID: hostID, kind: .exec) - await authentication.retry(after: error, generation: attemptedGeneration) - override = try await authentication.configurationAttempt() - } catch let error as TermindSSHError where error.isLocalAuthenticationRequirement { - override = try await authentication.configurationAttempt() - } catch TermindSSHError.untrustedHost { - await authentication.retryHostKey( - after: .untrustedHost, generation: attemptedGeneration - ) - override = try await authentication.configurationAttempt() - } catch let error as TermindSSHError - where error.isHostKeyChanged || error.shouldRequestHostKey - { - await pool.disconnect(hostID: hostID, kind: .exec) - await authentication.retryHostKey( - after: error, generation: attemptedGeneration - ) - override = try await authentication.configurationAttempt() + try await openAuthenticated( + hostID: hostID, authentication: authentication, pool: pool, kind: .exec + ) { attempt in + if let attempt { + return try await pool.exec(hostID: hostID) { _ in attempt.configuration } } + return try await pool.exec(hostID: hostID) } } @@ -699,43 +671,51 @@ actor AgentSSHAuthenticationRegistry { try await withSession( hostID: hostID, invocationID: invocationID, interaction: interaction ) { authentication in - var override: AgentSSHConfigurationSession.Attempt? - while true { - let attemptedGeneration: UInt64 - if let override { - attemptedGeneration = override.generation - } else { - attemptedGeneration = await authentication.currentGeneration - } - do { - if let override { - return try await pool.sftp(hostID: hostID) { _ in - override.configuration - } - } - return try await pool.sftp(hostID: hostID) - } catch let error as TermindSSHError where error.shouldRequestAuthentication { - await pool.disconnect(hostID: hostID, kind: .sftp) - await authentication.retry( - after: error, generation: attemptedGeneration - ) - override = try await authentication.configurationAttempt() - } catch let error as TermindSSHError where error.isLocalAuthenticationRequirement { - override = try await authentication.configurationAttempt() - } catch TermindSSHError.untrustedHost { - await authentication.retryHostKey( - after: .untrustedHost, generation: attemptedGeneration - ) - override = try await authentication.configurationAttempt() - } catch let error as TermindSSHError - where error.isHostKeyChanged || error.shouldRequestHostKey - { - await pool.disconnect(hostID: hostID, kind: .sftp) - await authentication.retryHostKey( - after: error, generation: attemptedGeneration - ) - override = try await authentication.configurationAttempt() + try await self.openAuthenticated( + hostID: hostID, authentication: authentication, pool: pool, kind: .sftp + ) { attempt in + if let attempt { + return try await pool.sftp(hostID: hostID) { _ in attempt.configuration } } + return try await pool.sftp(hostID: hostID) + } + } + } + + private func openAuthenticated( + hostID: Host.ID, authentication: AgentSSHConfigurationSession, + pool: AgentConnectionPool, kind: AgentConnectionPool.Kind, + open: @Sendable (AgentSSHConfigurationSession.Attempt?) async throws -> Client + ) async throws -> Client { + var attempt: AgentSSHConfigurationSession.Attempt? + while true { + let attemptedGeneration: UInt64 + if let attempt { + attemptedGeneration = attempt.generation + } else { + attemptedGeneration = await authentication.currentGeneration + } + do { + return try await open(attempt) + } catch let error as TermindSSHError where error.shouldRequestAuthentication { + await pool.disconnect(hostID: hostID, kind: kind) + await authentication.retry(after: error, generation: attemptedGeneration) + attempt = try await authentication.configurationAttempt() + } catch let error as TermindSSHError where error.isLocalAuthenticationRequirement { + attempt = try await authentication.configurationAttempt() + } catch TermindSSHError.untrustedHost { + await authentication.retryHostKey( + after: .untrustedHost, generation: attemptedGeneration + ) + attempt = try await authentication.configurationAttempt() + } catch let error as TermindSSHError + where error.isHostKeyChanged || error.shouldRequestHostKey + { + await pool.disconnect(hostID: hostID, kind: kind) + await authentication.retryHostKey( + after: error, generation: attemptedGeneration + ) + attempt = try await authentication.configurationAttempt() } } } diff --git a/Termind/Application/Agent/CommandGeneratorService.swift b/Termind/Application/Agent/CommandGeneratorService.swift index 908b0886..4fdb61e7 100644 --- a/Termind/Application/Agent/CommandGeneratorService.swift +++ b/Termind/Application/Agent/CommandGeneratorService.swift @@ -89,18 +89,14 @@ final class CommandGeneratorController: CommandGenerating { /// it only resolves the configured role and its already local credential. var isLocallyAvailable: Bool { guard let resolved = ai.resolve(.commandGenerator) else { return false } - let credentialRef = resolved.provider.usesVertex && resolved.provider.kind == .google - ? resolved.provider.vertex.credentialRef : resolved.provider.credentialRef - return ai.secret(for: credentialRef)?.isEmpty == false + return ai.secret(for: resolved.provider.effectiveCredentialRef)?.isEmpty == false } func generate(_ context: CommandGeneratorContext) async throws -> String { guard let resolved = ai.resolve(.commandGenerator) else { throw CommandGeneratorError.missingModel } - let credentialRef = resolved.provider.usesVertex && resolved.provider.kind == .google - ? resolved.provider.vertex.credentialRef : resolved.provider.credentialRef - guard let secret = ai.secret(for: credentialRef), !secret.isEmpty else { + guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty else { throw CommandGeneratorError.missingCredential } let reasoning = ModelCapabilityResolver.reasoning( diff --git a/Termind/Application/Agent/ConversationTitleService.swift b/Termind/Application/Agent/ConversationTitleService.swift index 50d65b0d..92387b53 100644 --- a/Termind/Application/Agent/ConversationTitleService.swift +++ b/Termind/Application/Agent/ConversationTitleService.swift @@ -113,9 +113,7 @@ final class ConversationTitleController: ConversationTitling { guard let resolved = ai.resolve(.chat) else { throw ConversationTitleError.missingModel } - let credentialRef = resolved.provider.usesVertex && resolved.provider.kind == .google - ? resolved.provider.vertex.credentialRef : resolved.provider.credentialRef - guard let secret = ai.secret(for: credentialRef), !secret.isEmpty else { + guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty else { throw ConversationTitleError.missingCredential } // `.off`, not `.low`. The shared clamp walks *upward* first, which is diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index cda1f8ec..9a539b39 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -334,7 +334,6 @@ enum TermindColor { static var primaryForeground: Color { Color(nsColor: .labelColor) } static var secondaryForeground: Color { Color(nsColor: .secondaryLabelColor) } - static var disabledForeground: Color { Color(nsColor: .disabledControlTextColor) } static var selectedForeground: Color { Color(nsColor: .alternateSelectedControlTextColor) } @@ -349,9 +348,6 @@ enum TermindColor { static var danger: Color { Color(nsColor: .systemRed) } static var nativeAccent: NSColor { .controlAccentColor } - static var nativeSuccess: NSColor { .systemGreen } - static var nativeWarning: NSColor { .systemOrange } - static var nativeDanger: NSColor { .systemRed } static var nativeSelectedForeground: NSColor { .alternateSelectedControlTextColor } /// CALayer stores a concrete `CGColor`; assigning a named dynamic NSColor @@ -391,8 +387,8 @@ enum Highlight { /// still macOS 15, where a native material is the honest fallback: it preserves /// translucency and accessibility settings without trying to paint a fake /// highlight gradient that can never respond like system glass. -private struct TermindGlassSurface: ViewModifier { - let cornerRadius: CGFloat +private struct TermindGlassShape: ViewModifier { + let shape: Shape let tint: Color? let isInteractive: Bool @@ -401,72 +397,18 @@ private struct TermindGlassSurface: ViewModifier { if #available(macOS 26.0, *) { content.glassEffect( .regular.tint(tint).interactive(isInteractive), - in: .rect(cornerRadius: cornerRadius) + in: shape ) } else { content .background { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(.regularMaterial) + shape.fill(.regularMaterial) if let tint { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(tint) + shape.fill(tint) } } .overlay { - RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) - .allowsHitTesting(false) - } - } - } -} - -private struct TermindGlassCapsule: ViewModifier { - let tint: Color? - let isInteractive: Bool - - @ViewBuilder - func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content.glassEffect( - .regular.tint(tint).interactive(isInteractive), - in: .capsule - ) - } else { - content - .background { - Capsule().fill(.regularMaterial) - if let tint { Capsule().fill(tint) } - } - .overlay { - Capsule() - .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) - .allowsHitTesting(false) - } - } - } -} - -private struct TermindGlassCircle: ViewModifier { - let tint: Color? - let isInteractive: Bool - - @ViewBuilder - func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content.glassEffect( - .regular.tint(tint).interactive(isInteractive), - in: .circle - ) - } else { - content - .background { - Circle().fill(.regularMaterial) - if let tint { Circle().fill(tint) } - } - .overlay { - Circle() + shape .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) .allowsHitTesting(false) } @@ -629,8 +571,8 @@ extension View { tint: Color? = nil, isInteractive: Bool = false ) -> some View { - modifier(TermindGlassSurface( - cornerRadius: cornerRadius, + modifier(TermindGlassShape( + shape: RoundedRectangle(cornerRadius: cornerRadius), tint: tint, isInteractive: isInteractive )) @@ -640,14 +582,18 @@ extension View { tint: Color? = nil, isInteractive: Bool = false ) -> some View { - modifier(TermindGlassCapsule(tint: tint, isInteractive: isInteractive)) + modifier(TermindGlassShape( + shape: Capsule(), tint: tint, isInteractive: isInteractive + )) } func termindGlassCircle( tint: Color? = nil, isInteractive: Bool = false ) -> some View { - modifier(TermindGlassCircle(tint: tint, isInteractive: isInteractive)) + modifier(TermindGlassShape( + shape: Circle(), tint: tint, isInteractive: isInteractive + )) } func termindGlassBar() -> some View { diff --git a/Termind/DesignSystem/LinuxDistributionPresentation.swift b/Termind/DesignSystem/LinuxDistributionPresentation.swift index 5af774d0..c471830c 100644 --- a/Termind/DesignSystem/LinuxDistributionPresentation.swift +++ b/Termind/DesignSystem/LinuxDistributionPresentation.swift @@ -4,19 +4,12 @@ import SwiftUI nonisolated struct LinuxDistributionIconEntry: Codable, Hashable, Sendable { var title: String - var slug: String var asset: String var ids: [String] var hex: String - var sourceURL: String - var license: String? } nonisolated struct LinuxDistributionIconCatalog: Codable, Sendable { - var source: String - var package: String - var version: String - var integrity: String var entries: [LinuxDistributionIconEntry] private static let bundled: Self? = { diff --git a/Termind/Domain/AI/ModelProvider.swift b/Termind/Domain/AI/ModelProvider.swift index 63e762ae..945f5180 100644 --- a/Termind/Domain/AI/ModelProvider.swift +++ b/Termind/Domain/AI/ModelProvider.swift @@ -135,6 +135,12 @@ nonisolated struct ModelProvider: Identifiable, Hashable, Sendable { // MARK: - Resolution + var usesVertexEndpoint: Bool { usesVertex && kind == .google } + + var effectiveCredentialRef: String { + usesVertexEndpoint ? vertex.credentialRef : credentialRef + } + var resolvedBaseURL: String { let typed = baseURL.trimmingCharacters(in: .whitespaces) let value = typed.isEmpty ? kind.defaultBaseURL : typed @@ -161,7 +167,7 @@ nonisolated struct ModelProvider: Identifiable, Hashable, Sendable { func resolvedRequestURL(model: String? = nil) -> String { let modelID = model ?? Self.modelToken - if usesVertex, kind == .google { + if usesVertexEndpoint { return "https://\(vertex.host)/v1/projects/\(vertex.projectID)" + "/locations/\(vertex.location)/publishers/google/models/\(modelID):generateContent" } @@ -172,14 +178,14 @@ nonisolated struct ModelProvider: Identifiable, Hashable, Sendable { /// Where `Fetch Models` goes. `nil` for Vertex — see `canFetchModels`. var resolvedModelsURL: String? { - guard !(usesVertex && kind == .google) else { return nil } + guard !usesVertexEndpoint else { return nil } return resolvedBaseURL + kind.modelsPath } // MARK: - State var hasCredential: Bool { - usesVertex && kind == .google ? vertex.isComplete : !credentialRef.isEmpty + usesVertexEndpoint ? vertex.isComplete : !credentialRef.isEmpty } /// Vertex is excluded deliberately, not for lack of an endpoint: its @@ -188,7 +194,7 @@ nonisolated struct ModelProvider: Identifiable, Hashable, Sendable { /// call, so a fetch would fill the list with things that do not work. var canFetchModels: Bool { guard hasCredential else { return false } - return !(usesVertex && kind == .google) + return !usesVertexEndpoint } /// Whether this wire protocol has anywhere to *put* a server-side search diff --git a/Termind/Features/AI/TranscriptNativeRows.swift b/Termind/Features/AI/TranscriptNativeRows.swift index d1190f43..9c711d3f 100644 --- a/Termind/Features/AI/TranscriptNativeRows.swift +++ b/Termind/Features/AI/TranscriptNativeRows.swift @@ -1271,7 +1271,7 @@ private enum NativeTranscriptFactory { NativeLabel( "• \(reason)", font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1), - color: riskColor(assessment.risk) + color: nativeRiskColor(assessment.risk) )) } stack.addArrangedSubview(reasons) @@ -1294,7 +1294,7 @@ private enum NativeTranscriptFactory { run.handler = { [weak run] in let deliver = { context.actions.deliverCommand(command.command, true) } if assessment.requiresSecondConfirmation { - confirmDestructiveCommand( + presentDestructiveCommandConfirmation( command.command, reasons: assessment.reasons, from: run, @@ -1507,34 +1507,6 @@ private enum NativeTranscriptFactory { } } - private static func riskColor(_ risk: CommandRisk) -> NSColor { - switch risk { - case .safe: .systemGreen - case .caution: .systemOrange - case .destructive: .systemRed - } - } - - private static func confirmDestructiveCommand( - _ command: String, - reasons: [String], - from view: NSView?, - action: @escaping () -> Void - ) { - let alert = NSAlert() - alert.alertStyle = .critical - alert.messageText = String(localized: "Run a destructive command?") - alert.informativeText = reasons.joined(separator: "\n") - alert.addButton(withTitle: String(localized: "Run \(command)")) - alert.addButton(withTitle: String(localized: "Cancel")) - if let window = view?.window { - alert.beginSheetModal(for: window) { response in - if response == .alertFirstButtonReturn { action() } - } - } else if alert.runModal() == .alertFirstButtonReturn { - action() - } - } } @MainActor diff --git a/Termind/Features/HostChooser/HostChooserView.swift b/Termind/Features/HostChooser/HostChooserView.swift index a7ca51ed..beded63a 100644 --- a/Termind/Features/HostChooser/HostChooserView.swift +++ b/Termind/Features/HostChooser/HostChooserView.swift @@ -604,72 +604,14 @@ struct HostChooserView: View { /// /// `isChecked` is nil outside batch mode, and then nothing is drawn — a single /// pick has one visual channel and does not need two. -nonisolated struct HostChooserListItem: Identifiable, Hashable, Sendable { +nonisolated struct HostChooserListItem: CursorHostingListItem, Sendable { let host: Host let isCursor: Bool let isChecked: Bool? var id: Host.ID { host.id } } -private struct HostChooserNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (HostChooserListItem) -> AnyView - let items: [HostChooserListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [HostChooserListItem], - @ViewBuilder renderer: @escaping @MainActor (HostChooserListItem) -> 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 let content = HostingListContent() - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = 52 - listView.rows { - ListRow(HostingListRow.self) - .height { _, _ in 52 } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [HostChooserListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - if let cursor = items.first(where: \.isCursor) { - listView.scrollToRow(with: cursor.id, at: .nearest, animated: false) - } - } - - private func configure(_ row: HostingListRow, item: HostChooserListItem) { - content.configure(row, item: item) - } - } -} +private typealias HostChooserNativeList = CursorHostingList struct HostChooserRow: View { let host: Host diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index b6412eb4..3a81da10 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -1317,7 +1317,7 @@ struct ExportKeyToHostsSheet: View { } } -nonisolated private struct ExportHostListItem: Identifiable, Hashable, Sendable { +nonisolated private struct ExportHostListItem: CursorHostingListItem, Sendable { let host: Host let isCursor: Bool let isSelected: Bool @@ -1327,65 +1327,7 @@ nonisolated private struct ExportHostListItem: Identifiable, Hashable, Sendable var id: Host.ID { host.id } } -private struct ExportHostNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (ExportHostListItem) -> AnyView - let items: [ExportHostListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [ExportHostListItem], - @ViewBuilder renderer: @escaping @MainActor (ExportHostListItem) -> 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 let content = HostingListContent() - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = 52 - listView.rows { - ListRow(HostingListRow.self) - .height { _, _ in 52 } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [ExportHostListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - if let cursor = items.first(where: \.isCursor) { - listView.scrollToRow(with: cursor.id, at: .nearest, animated: false) - } - } - - private func configure(_ row: HostingListRow, item: ExportHostListItem) { - content.configure(row, item: item) - } - } -} +private typealias ExportHostNativeList = CursorHostingList // MARK: - Key card diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 51760899..a9b8c1a7 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -173,6 +173,13 @@ final class SFTPSessionModel: Identifiable { var conflictCount: Int } + private struct RemoteConflictPreflight { + var directories: [String] + var directoryConflicts: Set + var targetKinds: [Bool?] + var conflictCount: Int + } + let id = UUID() let hostID: Host.ID let initialPath: String @@ -859,8 +866,6 @@ final class SFTPSessionModel: Identifiable { guard let client else { return } var jobs: [PreparedJob] = [] var directories: [String] = [] - var directoryConflicts: Set = [] - var conflicts = 0 do { for root in urls { let scoped = root.startAccessingSecurityScopedResource() @@ -900,33 +905,20 @@ final class SFTPSessionModel: Identifiable { )) } } - let orderedDirectories = directories.sorted { $0.count < $1.count } - for directory in orderedDirectories { - if directoryConflicts.contains(where: { directory.hasPrefix($0 + "/") }) { - continue - } - if let existing = try await client.attributes(at: directory), !existing.isDirectory { - directoryConflicts.insert(directory) - conflicts += 1 - } - } - for index in jobs.indices where jobs[index].direction == .upload { - if directoryConflicts.contains(where: { - jobs[index].remotePath.hasPrefix($0 + "/") - }) { - continue - } - if let existing = try await client.attributes(at: jobs[index].remotePath) { - jobs[index].hasConflict = true - jobs[index].conflictingTargetIsDirectory = existing.isDirectory - conflicts += 1 - } + let preflight = try await remoteConflictPreflight( + directories: directories, targets: jobs.map(\.remotePath), client: client + ) + for (index, kind) in zip(jobs.indices, preflight.targetKinds) { + guard let kind else { continue } + jobs[index].hasConflict = true + jobs[index].conflictingTargetIsDirectory = kind } pendingRemoteCopyBatch = nil pendingBatch = PreparedBatch( - jobs: jobs, remoteDirectories: orderedDirectories, - remoteDirectoryConflicts: directoryConflicts, - localDirectories: [], localDirectoryConflicts: [], conflictCount: conflicts + jobs: jobs, remoteDirectories: preflight.directories, + remoteDirectoryConflicts: preflight.directoryConflicts, + localDirectories: [], localDirectoryConflicts: [], + conflictCount: preflight.conflictCount ) } catch { operationError = error.localizedDescription } } @@ -987,9 +979,6 @@ final class SFTPSessionModel: Identifiable { let manifest = try await coordinator.remoteManifest(for: payload) var jobs: [PreparedRemoteCopyJob] = [] var directories: [String] = [] - var directoryConflicts: Set = [] - var conflicts = 0 - for entry in manifest.entries { let destination = RemotePath.join(path, entry.relativePath) if entry.isDirectory { @@ -1006,41 +995,58 @@ final class SFTPSessionModel: Identifiable { } } - let orderedDirectories = directories.sorted { $0.count < $1.count } - for directory in orderedDirectories { - if directoryConflicts.contains(where: { directory.hasPrefix($0 + "/") }) { - continue - } - if let existing = try await client.attributes(at: directory), !existing.isDirectory { - directoryConflicts.insert(directory) - conflicts += 1 - } - } - for index in jobs.indices { - if directoryConflicts.contains(where: { - jobs[index].destinationPath.hasPrefix($0 + "/") - }) { - continue - } - if let existing = try await client.attributes(at: jobs[index].destinationPath) { - jobs[index].hasConflict = true - jobs[index].conflictingTargetIsDirectory = existing.isDirectory - conflicts += 1 - } + let preflight = try await remoteConflictPreflight( + directories: directories, targets: jobs.map(\.destinationPath), client: client + ) + for (index, kind) in zip(jobs.indices, preflight.targetKinds) { + guard let kind else { continue } + jobs[index].hasConflict = true + jobs[index].conflictingTargetIsDirectory = kind } pendingBatch = nil pendingRemoteCopyBatch = PreparedRemoteCopyBatch( source: manifest.source, jobs: jobs, - remoteDirectories: orderedDirectories, - remoteDirectoryConflicts: directoryConflicts, - conflictCount: conflicts + remoteDirectories: preflight.directories, + remoteDirectoryConflicts: preflight.directoryConflicts, + conflictCount: preflight.conflictCount ) } catch { operationError = error.localizedDescription } } + private func remoteConflictPreflight( + directories: [String], targets: [String], client: any SFTPClientProtocol + ) async throws -> RemoteConflictPreflight { + let directories = directories.sorted { $0.count < $1.count } + var directoryConflicts: Set = [] + var targetKinds: [Bool?] = [] + var conflictCount = 0 + + for directory in directories { + guard !directoryConflicts.contains(where: { directory.hasPrefix($0 + "/") }) + else { continue } + if let existing = try await client.attributes(at: directory), !existing.isDirectory { + directoryConflicts.insert(directory) + conflictCount += 1 + } + } + for target in targets { + guard !directoryConflicts.contains(where: { target.hasPrefix($0 + "/") }) else { + targetKinds.append(nil) + continue + } + let kind = try await client.attributes(at: target)?.isDirectory + targetKinds.append(kind) + if kind != nil { conflictCount += 1 } + } + return RemoteConflictPreflight( + directories: directories, directoryConflicts: directoryConflicts, + targetKinds: targetKinds, conflictCount: conflictCount + ) + } + func open( _ file: RemoteFile, coordinator: SFTPTransferCoordinator, completion: @escaping (URL) -> Void diff --git a/Termind/Features/Settings/ModelListEditor.swift b/Termind/Features/Settings/ModelListEditor.swift index 725b506c..456221be 100644 --- a/Termind/Features/Settings/ModelListEditor.swift +++ b/Termind/Features/Settings/ModelListEditor.swift @@ -215,7 +215,7 @@ struct ModelListEditor: View { /// is the one people will otherwise read as a bug. private var fetchHelp: Text { guard let provider else { return Text("") } - if provider.usesVertex && provider.kind == .google { + if provider.usesVertexEndpoint { return Text(ModelCatalogError.unsupportedForVertex.localizedDescription) } if !provider.hasCredential { @@ -304,7 +304,7 @@ struct ModelListEditor: View { private var emptyHint: LocalizedStringKey { guard let provider else { return "" } - if provider.usesVertex && provider.kind == .google { + if provider.usesVertexEndpoint { return "Add Vertex models manually" } return provider.hasCredential ? "Press Fetch" : "Add a key, then press Fetch" @@ -657,9 +657,7 @@ struct ModelListEditor: View { guard let provider, provider.hasCredential, !state.isTesting, !targets.isEmpty else { return } - let secret = app.aiConfiguration.secret(for: provider.credentialRef) ?? "" - let vertexSecret = app.aiConfiguration.secret(for: provider.vertex.credentialRef) ?? "" - let credential = provider.usesVertex && provider.kind == .google ? vertexSecret : secret + let credential = app.aiConfiguration.secret(for: provider.effectiveCredentialRef) ?? "" state.isTesting = true for model in targets { diff --git a/Termind/Features/Settings/ModelProviderEditorSheet.swift b/Termind/Features/Settings/ModelProviderEditorSheet.swift index 72e73935..0d9e398b 100644 --- a/Termind/Features/Settings/ModelProviderEditorSheet.swift +++ b/Termind/Features/Settings/ModelProviderEditorSheet.swift @@ -82,7 +82,7 @@ struct ModelProviderEditorPage: View { } } - if provider.usesVertex && provider.kind == .google { + if provider.usesVertexEndpoint { vertexSection(provider) } else { endpointSection(provider) diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index d5c9526e..a2ea5551 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -168,7 +168,7 @@ struct SettingsPageView: View { SFTPSettings() } case .providers: - SettingsCollectionPage(title: "Providers", subtitle: "Endpoints and credentials used by the assistant.") { + SettingsFormPage(title: "Providers", subtitle: "Endpoints and credentials used by the assistant.") { ModelProviderSettings { id, isNew in route = .modelProvider(id, isNew: isNew) } @@ -192,7 +192,7 @@ struct SettingsPageView: View { WebSearchSettings() } case .mcp: - SettingsCollectionPage(title: "MCP", subtitle: "Remote MCP servers available to the assistant.") { + SettingsFormPage(title: "MCP", subtitle: "Remote MCP servers available to the assistant.") { MCPSettings { id, isNew in route = .mcpServer(id, isNew: isNew) } } case .mcpServer: @@ -443,23 +443,6 @@ struct SettingsFormPage: View { } } -struct SettingsCollectionPage: View { - let title: LocalizedStringKey - let subtitle: LocalizedStringKey - @ViewBuilder let content: Content - - var body: some View { - VStack(spacing: 0) { - SettingsPageHeader(title: title, subtitle: subtitle) - Divider() - Form { content } - .formStyle(.grouped) - .frame(maxWidth: Metrics.SettingsPage.contentWidth) - .frame(maxWidth: .infinity) - } - } -} - struct SettingsPageHeader: View { let title: LocalizedStringKey let subtitle: LocalizedStringKey diff --git a/Termind/Features/Snippets/SnippetsPageView.swift b/Termind/Features/Snippets/SnippetsPageView.swift index ecb08766..6a0b0a61 100644 --- a/Termind/Features/Snippets/SnippetsPageView.swift +++ b/Termind/Features/Snippets/SnippetsPageView.swift @@ -899,72 +899,14 @@ private struct SnippetTargetPickerSheet: View { } } -nonisolated private struct SnippetTargetListItem: Identifiable, Hashable, Sendable { +nonisolated private struct SnippetTargetListItem: CursorHostingListItem, Sendable { let host: Host let isCursor: Bool let isChecked: Bool var id: Host.ID { host.id } } -private struct SnippetTargetNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (SnippetTargetListItem) -> AnyView - let items: [SnippetTargetListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [SnippetTargetListItem], - @ViewBuilder renderer: @escaping @MainActor (SnippetTargetListItem) -> 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 let content = HostingListContent() - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = 52 - listView.rows { - ListRow(HostingListRow.self) - .height { _, _ in 52 } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [SnippetTargetListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - if let cursor = items.first(where: \.isCursor) { - listView.scrollToRow(with: cursor.id, at: .nearest, animated: false) - } - } - - private func configure(_ row: HostingListRow, item: SnippetTargetListItem) { - content.configure(row, item: item) - } - } -} +private typealias SnippetTargetNativeList = CursorHostingList struct SnippetPackageSheet: View { let existing: SnippetPackage? diff --git a/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift b/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift index fa0b5ebd..f87ebb86 100644 --- a/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift +++ b/Termind/Infrastructure/Cloud/VaultSyncPersistence.swift @@ -215,12 +215,7 @@ final class VaultSyncJournal { version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), payload: payload ) - if let row { row.apply(envelope, needsUpload: true) } - else { - let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) - context.insert(inserted) - entries[key.recordName] = inserted - } + try store(envelope, needsUpload: true, in: &entries) changed.insert(key) } @@ -231,12 +226,7 @@ final class VaultSyncJournal { version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), isDeleted: true ) - if let row { row.apply(envelope, needsUpload: true) } - else { - let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) - context.insert(inserted) - entries[key.recordName] = inserted - } + try store(envelope, needsUpload: true, in: &entries) changed.insert(key) } return VaultMutationSet(keys: changed) @@ -293,13 +283,7 @@ final class VaultSyncJournal { func acceptRemote(_ envelopes: [VaultEnvelope]) throws { var entries = try entriesByName() for envelope in envelopes { - if let row = entries[envelope.key.recordName] { - row.apply(envelope, needsUpload: false) - } else { - let row = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: false) - context.insert(row) - entries[envelope.key.recordName] = row - } + try store(envelope, needsUpload: false, in: &entries) } } @@ -368,12 +352,7 @@ final class VaultSyncJournal { version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), payload: payload ) - if let row { row.apply(envelope, needsUpload: true) } - else { - let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true) - context.insert(inserted) - entries[key.recordName] = inserted - } + try store(envelope, needsUpload: true, in: &entries) changed.insert(key) } @@ -431,6 +410,21 @@ final class VaultSyncJournal { return try context.fetch(descriptor).first } + private func store( + _ envelope: VaultEnvelope, needsUpload: Bool, + in entries: inout [String: PersistentVaultSyncEntry] + ) throws { + if let row = entries[envelope.key.recordName] { + row.apply(envelope, needsUpload: needsUpload) + } else { + let row = try PersistentVaultSyncEntry( + envelope: envelope, needsUpload: needsUpload + ) + context.insert(row) + entries[envelope.key.recordName] = row + } + } + private func entriesByName() throws -> [String: PersistentVaultSyncEntry] { Dictionary( try context.fetch(FetchDescriptor()).map { diff --git a/Termind/Infrastructure/LLM/ModelCatalogClient.swift b/Termind/Infrastructure/LLM/ModelCatalogClient.swift index d30ca8d5..34992151 100644 --- a/Termind/Infrastructure/LLM/ModelCatalogClient.swift +++ b/Termind/Infrastructure/LLM/ModelCatalogClient.swift @@ -30,7 +30,7 @@ nonisolated struct ModelCatalogClient: Sendable { /// Paginates where the vendor paginates, and stops at `pageLimit` pages so /// a server that always says "there's more" cannot spin forever. func models(for provider: ModelProvider, secret: String) async throws -> [AIModel] { - guard !(provider.usesVertex && provider.kind == .google) else { + guard !provider.usesVertexEndpoint else { throw ModelCatalogError.unsupportedForVertex } let base = provider.resolvedModelsURL ?? "" diff --git a/Termind/Infrastructure/Security/TrialEligibilityStore.swift b/Termind/Infrastructure/Security/TrialEligibilityStore.swift index 50fc3462..ab33c165 100644 --- a/Termind/Infrastructure/Security/TrialEligibilityStore.swift +++ b/Termind/Infrastructure/Security/TrialEligibilityStore.swift @@ -218,21 +218,6 @@ actor TrialEligibilityStore { } } - func expireForTesting(now: Date = .now) throws -> Bool { - guard let key = try keychain.installKey() else { return false } - let envelopes = [defaults.data(forKey: Self.defaultsKey), try keychain.proof()] - let proofs = envelopes.compactMap { data -> TrialProof? in - guard let data else { return nil } - return try? TrialProofCodec.verify(data, key: key) - } - guard var proof = proofs.min(by: { $0.startedAt < $1.startedAt }) else { - return false - } - proof.startedAt = now.addingTimeInterval(-Self.duration - 60) - proof.lastTrustedAt = now - try persist(proof, key: key) - return true - } #endif private func makeProof(startedAt: Date, now: Date) -> TrialProof { diff --git a/Termind/UI/Components/HostingListRow.swift b/Termind/UI/Components/HostingListRow.swift index 445a6908..527af14c 100644 --- a/Termind/UI/Components/HostingListRow.swift +++ b/Termind/UI/Components/HostingListRow.swift @@ -78,3 +78,69 @@ final class HostingListContent where .environment(\.colorScheme, colorScheme)) } } + +protocol CursorHostingListItem: Identifiable, Hashable { + var isCursor: Bool { get } +} + +/// Fixed-height hosting list shared by keyboard-driven chooser sheets. +struct CursorHostingList: NSViewRepresentable where + Item: CursorHostingListItem & SendableMetatype, Item.ID: Hashable +{ + typealias NSViewType = ListView + typealias Renderer = @MainActor (Item) -> AnyView + + let items: [Item] + let renderer: Renderer + @Environment(\.locale) private var locale + @Environment(\.colorScheme) private var colorScheme + + init( + items: [Item], + @ViewBuilder renderer: @escaping @MainActor (Item) -> 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, items: items, renderer: renderer, + locale: locale, colorScheme: colorScheme + ) + } + + @MainActor + final class Coordinator { + private let content = HostingListContent() + + func makeListView() -> NSViewType { + let listView = NSViewType() + listView.rowAnimator = ListBouncyAnimator(style: .regular) + listView.estimatedRowHeight = 52 + listView.rows { + ListRow(HostingListRow.self) + .height { _, _ in 52 } + .configure { [weak self] row, item, _ in + self?.content.configure(row, item: item) + } + } + return listView + } + + func update( + _ listView: NSViewType, items: [Item], renderer: @escaping Renderer, + locale: Locale, colorScheme: ColorScheme + ) { + content.update( + listView, items: items, renderer: renderer, locale: locale, + colorScheme: colorScheme, animated: true + ) + if let cursor = items.first(where: \.isCursor) { + listView.scrollToRow(with: cursor.id, at: .nearest, animated: false) + } + } + } +}