From 6527bdfb507f455b65d288fa63e7b3b76a2fc4bb Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 06:48:43 +0800 Subject: [PATCH] Restrict ListViewKit to native list workloads --- Memory/ARCHITECTURE.md | 10 + Memory/REFACTOR.md | 24 +++ TFM/Design/DesignTokens.swift | 2 +- TFM/Design/TagField.swift | 120 +++--------- TFM/Features/AI/ConversationViews.swift | 111 +++-------- TFM/Features/AI/SlashCommandMenuView.swift | 108 ++--------- .../HostChooser/HostChooserView.swift | 63 +++---- TFM/Features/Keychain/KeychainPageView.swift | 50 ++--- TFM/Features/QuickOpen/QuickOpenView.swift | 97 +++------- TFM/Features/Settings/ModelListEditor.swift | 172 ++++-------------- TFM/Features/Snippets/SnippetsPageView.swift | 47 +++-- TFM/Features/StatusBar/StatusBarView.swift | 100 ++-------- TFM/UI/Components/ConnectionJourneyView.swift | 120 ++---------- TFM/UI/Components/HostingListRow.swift | 71 -------- TFMTests/ArchitectureBoundaryTests.swift | 23 +++ 15 files changed, 282 insertions(+), 836 deletions(-) diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 3809ce40..d84b04d9 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -852,3 +852,13 @@ Mobile runs belong to the app, not the selected page. Switching conversations pr The mobile composer is a SwiftUI vertical TextField, with camera, photos, files and speech in its leading menu. UIKit is confined to camera/preview/audio adapters and system integration; no UIKit text editor replaces the composer. Speech appends text without sending, owns a conversation token, and cancels on interruption, conversation change or backgrounding. Agent runs may consume the finite background execution time already requested by the mobile shell. Expiration invalidates pending starts, drains cancellation events, closes Agent transports and prevents new starts until cleanup ends. Returning to the foreground never replays commands or approves waiting actions. Platform suspension still bounds execution; Live Activities do not keep Agent runs alive. + +### Desktop ListViewKit boundary (2026-09-12) + +ListViewKit is a macOS-only implementation detail reserved for lists with demonstrated native-list requirements: +the streaming Assistant transcript, large SFTP directories, the Assistant conversation library, and the window tool +sidebars. These surfaces depend on one or more of variable-height incremental refresh, imperative viewport control, +native drag-and-drop, detached-row cleanup, or blank-area AppKit menus. Compact menus, bounded popovers, settings +editors, and ordinary chooser sheets use SwiftUI `ScrollView` and `LazyVStack`; visual consistency or elastic +animation alone is not sufficient reason to add another ListViewKit bridge. An architecture test owns the production +import allowlist so this dependency cannot spread without an explicit boundary change. diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 34ade682..681edecd 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -1282,3 +1282,27 @@ issues also reproduce in focused isolation. Both app builds pass with pinned package versions. Strict Swift formatting, Simplified Chinese localization, `git diff --check`, forwarding-alias scans, and retired-name scans pass. No persistence schema, remote protocol, dependency version, localization catalog, or Assistant behavior changed. + +## 2026-09-12 — Restrict ListViewKit to native-list workloads + +**Status:** Completed on 2026-09-12; macOS build and focused ListView tests pass. + +**Evidence:** Production imported ListViewKit from 16 Swift files through 12 adapters and 19 mount sites. The package +was originally introduced for the unbounded, streaming Assistant transcript, then one migration applied the same +AppKit hosting, diff, reuse, and environment bridge to compact menus, bounded popovers, settings editors, and ordinary +chooser sheets. Those lightweight surfaces received no capability that SwiftUI `LazyVStack` could not provide. + +**Action:** Keep ListViewKit only for the transcript, SFTP directory, Assistant conversation library, window tool +sidebars, and their shared hosting row. Return Tag suggestions, Slash Commands, Quick Open, the model picker and model +editors, Transfer Center, Connection Log, and the three Host chooser variants to local SwiftUI lazy stacks. Enforce +the six-file production import boundary in `ArchitectureBoundaryTests`. + +**Estimated reduction:** Remove eight adapters and eleven mount sites while retaining the package for four proven +native-list workloads. + +**Result:** Production imports fell from 16 files to a six-file allowlist. Eight adapters and eleven mount sites were +removed; the TFM production diff adds 225 and removes 836 lines for a net reduction of 611 lines. The macOS build, +85 focused Architecture/ListView/Quick Open/SFTP tests, strict formatting, Simplified Chinese localization check, and +`git diff --check` pass. The complete TFM action ran 1,198 tests: all affected suites passed, while the same nine +environment/timing issues already recorded by the preceding verification remain in Data Protection Keychain, Metrics +popover focus, Agent cancellation, and approval timing. diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index 325e64a8..3c53aa1d 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -37,7 +37,7 @@ nonisolated enum Metrics { /// Shared content inset for the leading glyph and trailing accessory. static let navigationRowInset: CGFloat = 6 /// One shared breathing edge for every card in the left column. - /// Keeping it here makes SwiftUI List and ListViewKit rows line up. + /// Keeping it here makes list rows across the app line up. static let rowHorizontalMargin: CGFloat = 8 static let rowBackgroundInset: CGFloat = 0 static let rowBackgroundVerticalInset: CGFloat = 2 diff --git a/TFM/Design/TagField.swift b/TFM/Design/TagField.swift index 8340f0dc..0914a6c0 100644 --- a/TFM/Design/TagField.swift +++ b/TFM/Design/TagField.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI import TermindApplication @@ -143,21 +141,33 @@ struct TagField: View { } private var list: some View { - TagSuggestionNativeList(items: tagListItems) { item in - TagRowView(row: item.row, isHighlighted: item.isHighlighted) - // Single click commits: this is a picker, not a list of objects - // to select and then act on. - .onTapGesture { - commit(item.row) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.element) { index, row in + TagRowView(row: row, isHighlighted: highlighted == index) + .id(row) + // Single click commits: this is a picker, not a list of objects + // to select and then act on. + .onTapGesture { + commit(row) + } + } } + .padding(.vertical, Metrics.Space.xxs) + } + .onAppear { scrollHighlightIntoView(using: proxy) } + .onChange(of: highlighted) { _, _ in scrollHighlightIntoView(using: proxy) } + .onChange(of: rows) { _, _ in scrollHighlightIntoView(using: proxy) } } .frame(maxHeight: listMaxHeight) + .scrollBounceBehavior(.basedOnSize) + .fixedSize(horizontal: false, vertical: true) } - private var tagListItems: [TagSuggestionListItem] { - Array(rows.enumerated()).map { index, row in - TagSuggestionListItem(row: row, isHighlighted: highlighted == index) - } + private func scrollHighlightIntoView(using proxy: ScrollViewProxy) { + guard let highlighted, rows.indices.contains(highlighted) else { return } + proxy.scrollTo(rows[highlighted]) } // MARK: - Rows @@ -246,92 +256,6 @@ struct TagField: View { } } -nonisolated private struct TagSuggestionListItem: Identifiable, Hashable, Sendable { - let row: TagField.Row - let isHighlighted: Bool - var id: TagField.Row { row } -} - -private struct TagSuggestionNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (TagSuggestionListItem) -> AnyView - - let items: [TagSuggestionListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [TagSuggestionListItem], - @ViewBuilder renderer: @escaping @MainActor (TagSuggestionListItem) -> some View - ) { - self.items = items - self.renderer = { AnyView(renderer($0)) } - } - - func makeCoordinator() -> Coordinator { Coordinator() } - func makeNSView(context: Context) -> NSViewType { context.coordinator.makeListView() } - func updateNSView(_ listView: NSViewType, context: Context) { - context.coordinator.update( - listView: listView, items: items, renderer: renderer, - locale: locale, colorScheme: colorScheme - ) - } - - @MainActor - final class Coordinator { - private var renderer: Renderer? - private var locale = Locale.current - private var colorScheme = ColorScheme.light - private var content: [TagSuggestionListItem] = [] - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.topInset = Metrics.Space.xxs - listView.bottomInset = Metrics.Space.xxs - listView.estimatedRowHeight = 32 - listView.rows { - ListRow(HostingListRow.self) - .height { _, _ in 32 } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [TagSuggestionListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme - ) { - self.renderer = renderer - let environmentChanged = self.locale != locale || self.colorScheme != colorScheme - self.locale = locale - self.colorScheme = colorScheme - if content.map(\.id) == items.map(\.id) { - for (old, new) in zip(content, items) where old != new { - listView.update(new) - } - if environmentChanged { listView.reloadData() } - } else { - listView.apply(items, animated: !content.isEmpty) - } - content = items - if let highlighted = items.first(where: \.isHighlighted) { - listView.scrollToRow(with: highlighted.id, at: .nearest, animated: false) - } - } - - private func configure(_ row: HostingListRow, item: TagSuggestionListItem) { - guard let renderer else { return } - let root = renderer(item) - .id(item.id) - .environment(\.locale, locale) - .environment(\.colorScheme, colorScheme) - row.configure(rootView: AnyView(root)) - } - } -} - // MARK: - Row view private struct TagRowView: View { diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index 0d93b767..fa850dce 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -1,6 +1,5 @@ import AgentKit import AppKit -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -1693,28 +1692,35 @@ struct ComposerView: View { } private var modelOptions: some View { - AssistantModelOptionNativeList(items: modelOptionItems) { item in - switch item.content { - case .empty: - Text("No models configured") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Metrics.Space.m) - case .provider(_, let name): - Text(name) - .font(.caption2.weight(.medium)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Metrics.Space.m) - case .model(let provider, let model, let selected): - Button { - select(model, on: provider) - } label: { - pickerOptionRow(model.label, isSelected: selected) + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(modelOptionItems) { item in + Group { + switch item.content { + case .empty: + Text("No models configured") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Metrics.Space.m) + case .provider(_, let name): + Text(name) + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Metrics.Space.m) + case .model(let provider, let model, let selected): + Button { + select(model, on: provider) + } label: { + pickerOptionRow(model.label, isSelected: selected) + } + .buttonStyle(.plain) + .termindInputAwareFocusEffect() + } + } + .frame(height: item.height) } - .buttonStyle(.plain) - .termindInputAwareFocusEffect() } } // A max height alone has no intrinsic answer inside a newly presented @@ -1893,67 +1899,6 @@ nonisolated private struct AssistantModelOptionListItem: Identifiable, Hashable, } } -private struct AssistantModelOptionNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (AssistantModelOptionListItem) -> AnyView - - let items: [AssistantModelOptionListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [AssistantModelOptionListItem], - @ViewBuilder renderer: @escaping @MainActor (AssistantModelOptionListItem) -> 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 = 25 - listView.rows { - ListRow(HostingListRow.self) - .height { item, _ in item.height } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [AssistantModelOptionListItem], - renderer: @escaping Renderer, locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - } - - private func configure( - _ row: HostingListRow, - item: AssistantModelOptionListItem - ) { - content.configure(row, item: item) - } - } -} - private struct SpeechWaveformView: View { let samples: [Float] let reduceMotion: Bool diff --git a/TFM/Features/AI/SlashCommandMenuView.swift b/TFM/Features/AI/SlashCommandMenuView.swift index 0b868fc1..68e392df 100644 --- a/TFM/Features/AI/SlashCommandMenuView.swift +++ b/TFM/Features/AI/SlashCommandMenuView.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI import TermindApplication @@ -28,11 +26,22 @@ struct SlashCommandMenuView: View { var onSelect: (ChatSlashCommandCompletion.Row) -> Void var body: some View { - SlashCommandNativeList(items: listItems, scrollRequest: scrollRequest) { item in - row(item.row, isSelected: item.isSelected) - .onHover { inside in - if inside { onHover(item.index) } + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(completion.rows.enumerated()), id: \.element.id) { index, row in + self.row(row, isSelected: index == selection) + .id(row.id) + .onHover { inside in + if inside { onHover(index) } + } + } } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { scrollSelectionIntoView(using: proxy) } + .onChange(of: scrollRequest) { _, _ in scrollSelectionIntoView(using: proxy) } + .onChange(of: completion.rows) { _, _ in scrollSelectionIntoView(using: proxy) } } // Naturally sized until it would be taller than this. `basedOnSize` is // what stops a short list from rubber-banding when the pointer crosses @@ -43,16 +52,15 @@ struct SlashCommandMenuView: View { .accessibilityLabel(Text("Commands")) } - private var listItems: [SlashCommandListItem] { - Array(completion.rows.enumerated()).map { index, row in - SlashCommandListItem(index: index, row: row, isSelected: index == selection) - } - } - private var listHeight: CGFloat { min(Metrics.Chat.slashMenuMaxHeight, CGFloat(completion.rows.count) * 34) } + private func scrollSelectionIntoView(using proxy: ScrollViewProxy) { + guard completion.rows.indices.contains(selection) else { return } + proxy.scrollTo(completion.rows[selection].id) + } + private func row( _ row: ChatSlashCommandCompletion.Row, isSelected: Bool ) -> some View { @@ -127,79 +135,3 @@ struct SlashCommandMenuView: View { } } - -nonisolated private struct SlashCommandListItem: Identifiable, Hashable, Sendable { - let index: Int - let row: ChatSlashCommandCompletion.Row - let isSelected: Bool - var id: ChatSlashCommandCompletion.Row.ID { row.id } -} - -private struct SlashCommandNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (SlashCommandListItem) -> AnyView - - let items: [SlashCommandListItem] - let scrollRequest: Int - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [SlashCommandListItem], - scrollRequest: Int, - @ViewBuilder renderer: @escaping @MainActor (SlashCommandListItem) -> some View - ) { - self.items = items - self.scrollRequest = scrollRequest - 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, scrollRequest: scrollRequest, - renderer: renderer, locale: locale, colorScheme: colorScheme - ) - } - - @MainActor - final class Coordinator { - private let content = HostingListContent() - private var lastScrollRequest: Int? - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = 34 - listView.rows { - ListRow(HostingListRow.self) - .height { _, _ in 34 } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [SlashCommandListItem], scrollRequest: Int, - renderer: @escaping Renderer, locale: Locale, colorScheme: ColorScheme - ) { - let identitiesChanged = content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - - if identitiesChanged || lastScrollRequest != scrollRequest { - lastScrollRequest = scrollRequest - if let selected = items.first(where: \.isSelected) { - listView.scrollToRow(with: selected.id, at: .nearest, animated: false) - } - } - } - - private func configure(_ row: HostingListRow, item: SlashCommandListItem) { - content.configure(row, item: item) - } - } -} diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index f129ddb3..72ef933d 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -1,6 +1,4 @@ -import AppKit import Darwin -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -313,28 +311,41 @@ struct HostChooserView: View { } else if results.isEmpty { ContentUnavailableView.search(text: query) } else { - HostChooserNativeList(items: hostListItems) { item in - HostChooserRow( - host: item.host, - isSelected: item.isCursor, - isChecked: item.isChecked - ) - // Each ListViewKit row has an independent NSHostingView root. - .environment(app) - .onTapGesture(count: 2) { - if !purpose.allowsMultiple { - open(item.host) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(results) { host in + HostChooserRow( + host: host, + isSelected: chooser.cursor == host.id, + isChecked: purpose.allowsMultiple + ? chooser.selected.contains(host.id) : nil + ) + .frame(height: 52) + .id(host.id) + .onTapGesture(count: 2) { + if !purpose.allowsMultiple { open(host) } + } + .onTapGesture { + chooser.activate(host.id) + } + } } } - .onTapGesture { - chooser.activate(item.id) - } + .onAppear { scrollCursorIntoView(using: proxy) } + .onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) } + .onChange(of: results.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) } } .padding(.horizontal, Metrics.Space.l) .padding(.bottom, Metrics.Space.s) } } + private func scrollCursorIntoView(using proxy: ScrollViewProxy) { + guard let cursor = chooser.cursor else { return } + proxy.scrollTo(cursor) + } + private var serialForm: some View { Form { Section("Device") { @@ -401,17 +412,6 @@ struct HostChooserView: View { } } - private var hostListItems: [HostChooserListItem] { - results.map { host in - HostChooserListItem( - host: host, - isCursor: chooser.cursor == host.id, - isChecked: purpose.allowsMultiple - ? chooser.selected.contains(host.id) : nil - ) - } - } - private var footer: some View { HStack { if purpose.allowsMultiple, connectionProtocol != .serial { @@ -630,15 +630,6 @@ 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: CursorHostingListItem, Sendable { - let host: Host - let isCursor: Bool - let isChecked: Bool? - var id: Host.ID { host.id } -} - -private typealias HostChooserNativeList = CursorHostingList - struct HostChooserRow: View { let host: Host let isSelected: Bool diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index fa40beba..b4c1114c 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -1196,32 +1194,28 @@ struct ExportKeyToHostsSheet: View { } else if hosts.isEmpty { ContentUnavailableView.search(text: query) } else { - ExportHostNativeList(items: exportHostItems) { item in - row(for: item.host) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(hosts) { host in + row(for: host) + .frame(height: 52) + .id(host.id) + } + } + } + .onAppear { scrollCursorIntoView(using: proxy) } + .onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) } + .onChange(of: hosts.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) } } .padding(.horizontal, Metrics.Space.l) .padding(.vertical, Metrics.Space.s) } } - private var exportHostItems: [ExportHostListItem] { - hosts.map { host in - let stateKey: String - switch states[host.id] { - case .running: stateKey = "running" - case .succeeded: stateKey = "succeeded" - case .failed(let message): stateKey = "failed:\(message)" - case nil: stateKey = "idle" - } - return ExportHostListItem( - host: host, - isCursor: chooser.cursor == host.id, - isSelected: chooser.selected.contains(host.id), - unavailable: app.credentials.keyExportUnavailableReason(for: host), - stateKey: stateKey, - isDisabled: isExporting || isFinished - ) - } + private func scrollCursorIntoView(using proxy: ScrollViewProxy) { + guard let cursor = chooser.cursor else { return } + proxy.scrollTo(cursor) } private func row(for host: Host) -> some View { @@ -1421,18 +1415,6 @@ struct ExportKeyToHostsSheet: View { } } -nonisolated private struct ExportHostListItem: CursorHostingListItem, Sendable { - let host: Host - let isCursor: Bool - let isSelected: Bool - let unavailable: String? - let stateKey: String - let isDisabled: Bool - var id: Host.ID { host.id } -} - -private typealias ExportHostNativeList = CursorHostingList - // MARK: - Key card /// The singular actions for one key, shared by its card and editor surfaces. diff --git a/TFM/Features/QuickOpen/QuickOpenView.swift b/TFM/Features/QuickOpen/QuickOpenView.swift index d979af51..71aa3959 100644 --- a/TFM/Features/QuickOpen/QuickOpenView.swift +++ b/TFM/Features/QuickOpen/QuickOpenView.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI /// The window-level presentation for Command-P. @@ -146,13 +144,26 @@ struct QuickOpenView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .combine) } else { - QuickOpenNativeList(items: listItems) { item in - switch item { - case .section(let section): - quickOpenSection(section) - case .command(let command, let isSelected): - quickOpenCommand(command, isSelected: isSelected) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(listItems) { item in + Group { + switch item { + case .section(let section): + quickOpenSection(section) + case .command(let command, let isSelected): + quickOpenCommand(command, isSelected: isSelected) + } + } + .frame(height: item.height) + .id(item.id) + } + } + .padding(.vertical, Metrics.Space.xs) } + .onAppear { scrollSelectionIntoView(using: proxy) } + .onChange(of: selectedID) { _, _ in scrollSelectionIntoView(using: proxy) } } } } @@ -161,6 +172,11 @@ struct QuickOpenView: View { results.listItems(selectedID: selectedID) } + private func scrollSelectionIntoView(using proxy: ScrollViewProxy) { + guard let selectedID else { return } + proxy.scrollTo(QuickOpenListItem.ID.command(selectedID)) + } + private func quickOpenSection(_ section: AppCommandSection) -> some View { Text(section.title) .font(.caption.weight(.semibold)) @@ -367,68 +383,3 @@ nonisolated enum QuickOpenListItem: Identifiable, Hashable, Sendable { return isSelected } } - -private struct QuickOpenNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (QuickOpenListItem) -> AnyView - - let items: [QuickOpenListItem] - let renderer: Renderer - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [QuickOpenListItem], - @ViewBuilder renderer: @escaping @MainActor (QuickOpenListItem) -> 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, animated: !reduceMotion - ) - } - - @MainActor - final class Coordinator { - private let content = HostingListContent() - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.topInset = Metrics.Space.xs - listView.bottomInset = Metrics.Space.xs - listView.estimatedRowHeight = Metrics.QuickOpen.rowHeight - listView.rows { - ListRow(HostingListRow.self) - .height { item, _ in item.height } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [QuickOpenListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme, animated: Bool - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: animated - ) - if let selected = items.first(where: \.isSelected) { - listView.scrollToRow(with: selected.id, at: .nearest, animated: false) - } - } - - private func configure(_ row: HostingListRow, item: QuickOpenListItem) { - content.configure(row, item: item) - } - } -} diff --git a/TFM/Features/Settings/ModelListEditor.swift b/TFM/Features/Settings/ModelListEditor.swift index 86bf5517..0aab3dc5 100644 --- a/TFM/Features/Settings/ModelListEditor.swift +++ b/TFM/Features/Settings/ModelListEditor.swift @@ -1,6 +1,4 @@ import AgentKit -import AppKit -import ListViewKit import SwiftUI /// A provider's full-width models page: a checkable list and details for the @@ -263,36 +261,28 @@ struct ModelListEditor: View { .padding(.horizontal, Metrics.Space.l) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ModelEditorNativeList(items: modelListItems, rowHeight: 56) { item in - let model = item.model - ModelRow( - model: model, - isSelected: item.isSelected, - isChecked: item.isChecked, - result: item.result, - canTest: item.canTest, - select: { state.selection = model.id }, - check: { toggleCheck(model.id) }, - edit: { state.selection = model.id }, - test: { testModels([model]) }, - delete: { deleteModels([model.id]) } - ) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(models) { model in + ModelRow( + model: model, + isSelected: state.selection == model.id, + isChecked: state.checked.contains(model.id), + result: state.results[model.id], + canTest: provider?.hasCredential == true && !state.isTesting, + select: { state.selection = model.id }, + check: { toggleCheck(model.id) }, + edit: { state.selection = model.id }, + test: { testModels([model]) }, + delete: { deleteModels([model.id]) } + ) + .frame(height: 56) + } + } } } } - private var modelListItems: [SavedModelListItem] { - models.map { - SavedModelListItem( - model: $0, - isSelected: state.selection == $0.id, - isChecked: state.checked.contains($0.id), - result: state.results[$0.id], - canTest: provider?.hasCredential == true && !state.isTesting - ) - } - } - private var emptyHint: LocalizedStringKey { guard let provider else { return "" } if provider.resolvedModelsURL == nil { return "Add a URL, then press Fetch" } @@ -837,27 +827,32 @@ private struct CandidatePicker: View { } private var list: some View { - ModelEditorNativeList(items: candidateListItems, rowHeight: 52) { item in - CandidateRow( - model: item.model, - isAlreadyAdded: item.isAlreadyAdded, - isCursor: item.isCursor, - isChosen: item.isChosen - ) { - chooser.activate(item.id) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filtered) { model in + CandidateRow( + model: model, + isAlreadyAdded: existing.contains(model.id), + isCursor: chooser.cursor == model.id, + isChosen: chooser.selected.contains(model.id) + ) { + chooser.activate(model.id) + } + .frame(height: 52) + .id(model.id) + } + } } + .onAppear { scrollCursorIntoView(using: proxy) } + .onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) } + .onChange(of: filtered.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) } } } - private var candidateListItems: [CandidateModelListItem] { - filtered.map { - CandidateModelListItem( - model: $0, - isAlreadyAdded: existing.contains($0.id), - isCursor: chooser.cursor == $0.id, - isChosen: chooser.selected.contains($0.id) - ) - } + private func scrollCursorIntoView(using proxy: ScrollViewProxy) { + guard let cursor = chooser.cursor else { return } + proxy.scrollTo(cursor) } private var footer: some View { @@ -887,93 +882,6 @@ private struct CandidatePicker: View { } } -nonisolated private struct SavedModelListItem: Identifiable, Hashable, Sendable { - let model: AIModel - let isSelected: Bool - let isChecked: Bool - let result: ModelListEditor.TestResult? - let canTest: Bool - var id: AIModel.ID { model.id } -} - -nonisolated private struct CandidateModelListItem: Identifiable, Hashable, Sendable { - let model: AIModel - let isAlreadyAdded: Bool - let isCursor: Bool - let isChosen: Bool - var id: AIModel.ID { model.id } -} - -private struct ModelEditorNativeList: NSViewRepresentable -where Item: Identifiable & Hashable & Sendable, Item.ID: Hashable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (Item) -> AnyView - - let items: [Item] - let rowHeight: CGFloat - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [Item], - rowHeight: CGFloat, - @ViewBuilder renderer: @escaping @MainActor (Item) -> some View - ) { - self.items = items - self.rowHeight = rowHeight - self.renderer = { AnyView(renderer($0)) } - } - - func makeCoordinator() -> Coordinator { Coordinator(rowHeight: rowHeight) } - 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 rowHeight: CGFloat - private let content = HostingListContent() - - // Swift 6.3.3's optimizer can crash while synthesizing this generic deinit. - @_optimize(none) deinit {} - - init(rowHeight: CGFloat) { - self.rowHeight = rowHeight - } - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = rowHeight - listView.rows { - ListRow(HostingListRow.self) - .height { [rowHeight] _, _ in rowHeight } - .configure { [weak self] row, item, _ in self?.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 - ) - } - - private func configure(_ row: HostingListRow, item: Item) { - content.configure(row, item: item) - } - } -} - private struct CandidateRow: View { let model: AIModel let isAlreadyAdded: Bool diff --git a/TFM/Features/Snippets/SnippetsPageView.swift b/TFM/Features/Snippets/SnippetsPageView.swift index 995b0ebc..1944681a 100644 --- a/TFM/Features/Snippets/SnippetsPageView.swift +++ b/TFM/Features/Snippets/SnippetsPageView.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -822,27 +820,33 @@ private struct SnippetTargetPickerSheet: View { } else if results.isEmpty { ContentUnavailableView.search(text: query) } else { - SnippetTargetNativeList(items: targetListItems) { item in - HostChooserRow( - host: item.host, - isSelected: item.isCursor, - isChecked: item.isChecked - ) - .onTapGesture { chooser.activate(item.id) } + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(results) { host in + HostChooserRow( + host: host, + isSelected: chooser.cursor == host.id, + isChecked: chooser.selected.contains(host.id) + ) + .frame(height: 52) + .id(host.id) + .onTapGesture { chooser.activate(host.id) } + } + } + } + .onAppear { scrollCursorIntoView(using: proxy) } + .onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) } + .onChange(of: results.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) } } .padding(.horizontal, Metrics.Space.l) .padding(.bottom, Metrics.Space.s) } } - private var targetListItems: [SnippetTargetListItem] { - results.map { - SnippetTargetListItem( - host: $0, - isCursor: chooser.cursor == $0.id, - isChecked: chooser.selected.contains($0.id) - ) - } + private func scrollCursorIntoView(using proxy: ScrollViewProxy) { + guard let cursor = chooser.cursor else { return } + proxy.scrollTo(cursor) } private var footer: some View { @@ -892,15 +896,6 @@ private struct SnippetTargetPickerSheet: View { } } -nonisolated private struct SnippetTargetListItem: CursorHostingListItem, Sendable { - let host: Host - let isCursor: Bool - let isChecked: Bool - var id: Host.ID { host.id } -} - -private typealias SnippetTargetNativeList = CursorHostingList - struct SnippetPackageSheet: View { let existing: SnippetPackage? let save: (SnippetPackage) -> Void diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index 434919a2..c8c489f2 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -1,5 +1,3 @@ -import AppKit -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -495,14 +493,18 @@ private struct TransferCenterPopover: View { Divider() - TransferCenterNativeList(items: transferItems) { item in - VStack(spacing: 0) { - TransferCenterRow( - transfer: item.transfer, - route: item.route, - cancel: { coordinator.cancel(item.id) } - ) - if item.id != transferItems.last?.id { Divider() } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(transferItems) { item in + VStack(spacing: 0) { + TransferCenterRow( + transfer: item.transfer, + route: item.route, + cancel: { coordinator.cancel(item.id) } + ) + if item.id != transferItems.last?.id { Divider() } + } + } } } .frame(height: Metrics.TransferCenter.listHeight(itemCount: transferItems.count)) @@ -555,84 +557,6 @@ nonisolated struct TransferCenterListItem: Identifiable, Hashable, Sendable { var id: SFTPTransfer.ID { transfer.id } } -private struct TransferCenterNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (TransferCenterListItem) -> AnyView - - let items: [TransferCenterListItem] - let renderer: Renderer - - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [TransferCenterListItem], - @ViewBuilder renderer: @escaping @MainActor (TransferCenterListItem) -> 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() - private let measurement = NSHostingController(rootView: AnyView(EmptyView())) - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.estimatedRowHeight = Metrics.TransferCenter.estimatedRowHeight - listView.rows { - ListRow(HostingListRow.self) - .estimatedHeight(Metrics.TransferCenter.estimatedRowHeight) - .height { [weak self] item, context in - self?.height(for: item, width: context.width) - ?? Metrics.TransferCenter.estimatedRowHeight - } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [TransferCenterListItem], - renderer: @escaping Renderer, locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, renderer: renderer, locale: locale, - colorScheme: colorScheme, animated: true - ) - } - - private func height(for item: TransferCenterListItem, width: CGFloat) -> CGFloat { - guard width > 0, let root = content.rootView(for: item) else { - return Metrics.TransferCenter.estimatedRowHeight - } - measurement.rootView = root - return ceil( - measurement.sizeThatFits( - in: CGSize( - width: width, height: .greatestFiniteMagnitude - ) - ).height) - } - - private func configure(_ row: HostingListRow, item: TransferCenterListItem) { - content.configure(row, item: item) - } - } -} - private struct TransferCenterRow: View { let transfer: SFTPTransfer let route: String diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index 5f06e641..642e48e4 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -1,5 +1,4 @@ import AppKit -import ListViewKit import SwiftUI import TermindApplication import TermindDomain @@ -238,15 +237,21 @@ struct ConnectionJourneyView: View { } .padding(14) Divider() - ConnectionLogNativeList(items: connectionLogItems) { entry in - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(entry.date, format: .dateTime.hour().minute().second()) - .foregroundStyle(.tertiary) - Text(entry.endpoint).foregroundStyle(.secondary) - Text(entry.message) + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(journey.logs) { entry in + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(entry.date, format: .dateTime.hour().minute().second()) + .foregroundStyle(.tertiary) + Text(entry.endpoint).foregroundStyle(.secondary) + Text(entry.message) + } + .font(.caption.monospaced()) + .textSelection(.enabled) + } } - .font(.caption.monospaced()) - .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) } .frame(maxHeight: 210) } @@ -256,14 +261,6 @@ struct ConnectionJourneyView: View { } } - private var connectionLogItems: [ConnectionLogListItem] { - journey.logs.map { - ConnectionLogListItem( - id: $0.id, date: $0.date, endpoint: $0.endpoint, message: $0.message - ) - } - } - private func copyLogs() { #if canImport(AppKit) NSPasteboard.general.clearContents() @@ -272,13 +269,6 @@ struct ConnectionJourneyView: View { } } -nonisolated private struct ConnectionLogListItem: Identifiable, Hashable, Sendable { - let id: UUID - let date: Date - let endpoint: String - let message: String -} - /// The system window surface shared by a connection journey and its pane header. struct ConnectionJourneyBackground: View { var body: some View { @@ -286,88 +276,6 @@ struct ConnectionJourneyBackground: View { } } -private struct ConnectionLogNativeList: NSViewRepresentable { - typealias NSViewType = ListView - typealias Renderer = @MainActor (ConnectionLogListItem) -> AnyView - let items: [ConnectionLogListItem] - let renderer: Renderer - @Environment(\.locale) private var locale - @Environment(\.colorScheme) private var colorScheme - - init( - items: [ConnectionLogListItem], - @ViewBuilder renderer: @escaping @MainActor (ConnectionLogListItem) -> 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() - private let measurement = NSHostingController(rootView: AnyView(EmptyView())) - - func makeListView() -> NSViewType { - let listView = NSViewType() - listView.rowAnimator = ListBouncyAnimator(style: .regular) - listView.topInset = 14 - listView.bottomInset = 14 - listView.estimatedRowHeight = 26 - listView.rows { - ListRow(HostingListRow.self) - .estimatedHeight(26) - .height { [weak self] item, context in - self?.height(for: item, width: context.width) ?? 26 - } - .configure { [weak self] row, item, _ in self?.configure(row, item: item) } - } - return listView - } - - func update( - listView: NSViewType, items: [ConnectionLogListItem], renderer: @escaping Renderer, - locale: Locale, colorScheme: ColorScheme - ) { - content.update( - listView, items: items, - renderer: { item in - AnyView( - renderer(item) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14)) - }, - locale: locale, colorScheme: colorScheme, animated: true - ) - } - - private func height(for item: ConnectionLogListItem, width: CGFloat) -> CGFloat { - guard width > 0, let root = content.rootView(for: item) else { return 26 } - measurement.rootView = root - return max( - 26, - ceil( - measurement.sizeThatFits( - in: CGSize( - width: width, height: .greatestFiniteMagnitude - ) - ).height) + 8) - } - - private func configure(_ row: HostingListRow, item: ConnectionLogListItem) { - content.configure(row, item: item) - } - } -} - /// Shared SSH credential form used by both the connection Journey and Agent. /// The containing surface owns placement and queue state; this view owns the /// established Custom/Identity choices, button language, and submission flow. diff --git a/TFM/UI/Components/HostingListRow.swift b/TFM/UI/Components/HostingListRow.swift index 1245708e..debab220 100644 --- a/TFM/UI/Components/HostingListRow.swift +++ b/TFM/UI/Components/HostingListRow.swift @@ -80,74 +80,3 @@ 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() - - // Work around Swift 6.3.3 crashing in EarlyPerfInliner for the - // synthesized deinit of this generic nested coordinator. - @_optimize(none) deinit {} - - 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) - } - } - } -} diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 36515f35..f6f84899 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -363,6 +363,29 @@ struct ArchitectureBoundaryTests { try assertNoConcreteImplementations(in: "Features") } + @Test + func listViewKitIsLimitedToNativeListWorkloads() throws { + let allowed: Set = [ + "Features/AI/TranscriptListView.swift", + "Features/AI/TranscriptNativeRows.swift", + "Features/Assistant/AssistantView.swift", + "Features/SFTP/SFTPBrowserView.swift", + "Features/Sidebar/WindowSidebarView.swift", + "UI/Components/HostingListRow.swift", + ] + let importers = try Set( + swiftFiles(in: "").compactMap { file -> String? in + let text = try String(contentsOf: file, encoding: .utf8) + guard text.split(separator: "\n").contains("import ListViewKit") else { + return nil + } + return file.path.replacingOccurrences(of: sourceRoot.path + "/", with: "") + } + ) + + #expect(importers == allowed) + } + private func assertNoImports(in directory: String, forbidden: Set) throws { for file in try swiftFiles(in: directory) { let text = try String(contentsOf: file, encoding: .utf8)