refactor: reduce duplicated Swift code

This commit is contained in:
Kaede Akino committed 2026-08-30 11:54:48 +08:00
1 parent 098c5b01a9
commit d8d20a3548
22 files changed
+387 -515

No files matched your search

+145
View File
@@ -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.
+6 -12
View File
@@ -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)
-4
View File
@@ -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()
@@ -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.
@@ -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<Client: Sendable>(
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()
}
}
}
@@ -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(
@@ -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
+14 -68
View File
@@ -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<Shape: InsettableShape>: 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 {
@@ -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? = {
+10 -4
View File
@@ -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
+2 -30
View File
@@ -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
@@ -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<HostChooserListItem>
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<HostChooserListItem>()
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<HostChooserListItem>
struct HostChooserRow: View {
let host: Host
@@ -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<ExportHostListItem>
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<ExportHostListItem>()
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<ExportHostListItem>
// MARK: - Key card
+59 -53
View File
@@ -173,6 +173,13 @@ final class SFTPSessionModel: Identifiable {
var conflictCount: Int
}
private struct RemoteConflictPreflight {
var directories: [String]
var directoryConflicts: Set<String>
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<String> = []
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<String> = []
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<String> = []
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
@@ -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 {
@@ -82,7 +82,7 @@ struct ModelProviderEditorPage: View {
}
}
if provider.usesVertex && provider.kind == .google {
if provider.usesVertexEndpoint {
vertexSection(provider)
} else {
endpointSection(provider)
@@ -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<Content: View>: View {
}
}
struct SettingsCollectionPage<Content: View>: 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
@@ -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<SnippetTargetListItem>
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<SnippetTargetListItem>()
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<SnippetTargetListItem>
struct SnippetPackageSheet: View {
let existing: SnippetPackage?
@@ -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<PersistentVaultSyncEntry>()).map {
@@ -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 ?? ""
@@ -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 {
@@ -78,3 +78,69 @@ final class HostingListContent<Item> where
.environment(\.colorScheme, colorScheme))
}
}
protocol CursorHostingListItem: Identifiable, Hashable {
var isCursor: Bool { get }
}
/// Fixed-height hosting list shared by keyboard-driven chooser sheets.
struct CursorHostingList<Item>: NSViewRepresentable where
Item: CursorHostingListItem & SendableMetatype, Item.ID: Hashable
{
typealias NSViewType = ListView<Item>
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<Item>()
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)
}
}
}
}