Restrict ListViewKit to native list workloads

This commit is contained in:
Kaede Akino committed 2026-09-12 10:36:22 +08:00
1 parent 2d5aec9dbc
commit 6527bdfb50
15 files changed
+282 -836

No files matched your search

+10
View File
@@ -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.
+24
View File
@@ -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.
+1 -1
View File
@@ -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
+22 -98
View File
@@ -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<TagSuggestionListItem>
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 {
+28 -83
View File
@@ -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<AssistantModelOptionListItem>
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<AssistantModelOptionListItem>()
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
+20 -88
View File
@@ -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<SlashCommandListItem>
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<SlashCommandListItem>()
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)
}
}
}
+27 -36
View File
@@ -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<HostChooserListItem>
struct HostChooserRow: View {
let host: Host
let isSelected: Bool
+16 -34
View File
@@ -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<ExportHostListItem>
// MARK: - Key card
/// The singular actions for one key, shared by its card and editor surfaces.
+24 -73
View File
@@ -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<QuickOpenListItem>
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<QuickOpenListItem>()
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)
}
}
}
+40 -132
View File
@@ -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<Item>: NSViewRepresentable
where Item: Identifiable & Hashable & Sendable, Item.ID: Hashable {
typealias NSViewType = ListView<Item>
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<Item>()
// 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
+21 -26
View File
@@ -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<SnippetTargetListItem>
struct SnippetPackageSheet: View {
let existing: SnippetPackage?
let save: (SnippetPackage) -> Void
+12 -88
View File
@@ -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<TransferCenterListItem>
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<TransferCenterListItem>()
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
+14 -106
View File
@@ -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<ConnectionLogListItem>
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<ConnectionLogListItem>()
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.
-71
View File
@@ -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<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>()
// 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)
}
}
}
}
+23
View File
@@ -363,6 +363,29 @@ struct ArchitectureBoundaryTests {
try assertNoConcreteImplementations(in: "Features")
}
@Test
func listViewKitIsLimitedToNativeListWorkloads() throws {
let allowed: Set<String> = [
"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<String>) throws {
for file in try swiftFiles(in: directory) {
let text = try String(contentsOf: file, encoding: .utf8)