Fix SFTP table AttributeGraph crash

This commit is contained in:
Kaede Akino committed 2026-08-25 07:52:10 +08:00
1 parent 977dbb872e
commit 4998f12372
3 files changed
+102 -11

No files matched your search

+10 -3
View File
@@ -242,7 +242,13 @@ struct SFTPBrowserView: View {
@ViewBuilder
private var browserContent: some View {
Group {
// Keep the AppKit-backed Table mounted while its rows, selection, and
// connection state change. Removing it during an outline-view update
// leaves orphan NSTableRowViews whose AttributeGraph subgraph has
// already been dismantled ("precondition failure: no subgraph").
ZStack {
fileTable
if sftpSession.status == .disconnected, !sftpSession.connectionJourney.isPresented {
VStack(spacing: Metrics.Space.l) {
Image(systemName: "folder.badge.questionmark")
@@ -252,12 +258,13 @@ struct SFTPBrowserView: View {
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
} else if sftpSession.files.isEmpty, sftpSession.isLoading {
centeredState("Loading", symbol: "arrow.triangle.2.circlepath", progress: true)
.background(.background)
} else if sftpSession.files.isEmpty, sftpSession.status == .connected {
centeredState("This folder is empty", symbol: "folder", progress: false)
} else {
fileTable
.background(.background)
}
}
.onDrop(
+54 -8
View File
@@ -1,5 +1,27 @@
import Foundation
/// State consumed by SwiftUI's macOS `Table` must change as one value.
///
/// `Table` is backed by `NSOutlineView`. Publishing its rows, filter, and
/// selection independently can briefly leave the outline view selecting an ID
/// whose row has already become an orphan. On macOS that can make SwiftUI ask
/// AttributeGraph for the orphan row's dismantled subgraph and trap with
/// "precondition failure: no subgraph".
private struct SFTPDirectoryPresentation {
var files: [RemoteFile] = []
var selection: Set<RemoteFile.ID> = []
var query = ""
var filteredFiles: [RemoteFile] {
guard !query.isEmpty else { return files }
return files.filter { $0.name.localizedCaseInsensitiveContains(query) }
}
mutating func reconcileSelection() {
selection.formIntersection(Set(filteredFiles.map(\.id)))
}
}
@Observable
final class SFTPSessionModel: Identifiable {
struct PreparedJob {
@@ -46,9 +68,26 @@ final class SFTPSessionModel: Identifiable {
var pathText: String
private(set) var backHistory: [String] = []
private(set) var forwardHistory: [String] = []
var selection: Set<RemoteFile.ID> = []
var query = ""
private(set) var files: [RemoteFile] = []
private var directoryPresentation = SFTPDirectoryPresentation()
var selection: Set<RemoteFile.ID> {
get { directoryPresentation.selection }
set {
var presentation = directoryPresentation
presentation.selection = newValue
presentation.reconcileSelection()
directoryPresentation = presentation
}
}
var query: String {
get { directoryPresentation.query }
set {
var presentation = directoryPresentation
presentation.query = newValue
presentation.reconcileSelection()
directoryPresentation = presentation
}
}
var files: [RemoteFile] { directoryPresentation.files }
private(set) var status: ConnectionStatus = .disconnected {
didSet {
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
@@ -115,8 +154,7 @@ final class SFTPSessionModel: Identifiable {
pendingRemoteCopyBatch?.conflictCount ?? pendingBatch?.conflictCount ?? 0
}
var filteredFiles: [RemoteFile] {
guard !query.isEmpty else { return files }
return files.filter { $0.name.localizedCaseInsensitiveContains(query) }
directoryPresentation.filteredFiles
}
var selectedFiles: [RemoteFile] {
files.filter { selection.contains($0.id) }
@@ -318,7 +356,7 @@ final class SFTPSessionModel: Identifiable {
usesEphemeralCredentials = false
status = .disconnected
connectionJourney.hide()
files = []
replaceDirectoryContents(with: [])
Task { [id] in
await transferCoordinator?.cancelTransfers(usingBorrowedConnection: id)
await client?.disconnect()
@@ -502,8 +540,7 @@ final class SFTPSessionModel: Identifiable {
do {
let snapshot = try await client.listDirectory(at: requestedPath)
guard status == .connected, path == requestedPath else { return }
files = snapshot
selection.formIntersection(Set(snapshot.map(\.id)))
replaceDirectoryContents(with: snapshot)
hint =
snapshot.count == 1
? String(localized: "1 item") : String(localized: "\(snapshot.count) items")
@@ -540,6 +577,15 @@ final class SFTPSessionModel: Identifiable {
}
}
/// Publishes rows and their valid selection in one observable mutation so
/// SwiftUI never receives an intermediate table state.
private func replaceDirectoryContents(with files: [RemoteFile]) {
var presentation = directoryPresentation
presentation.files = files
presentation.reconcileSelection()
directoryPresentation = presentation
}
func scheduleSilentRefresh(after delay: Duration = .milliseconds(250)) {
uploadRefreshTask?.cancel()
uploadRefreshTask = Task { [weak self] in
+38
View File
@@ -173,6 +173,44 @@ struct SFTPFeatureTests {
#expect(sftpSession.files == [updated])
}
@Test
func directoryRefreshReconcilesRowsAndSelection() async {
let retained = remoteFile(path: "/home/retained.txt", kind: .file)
let removed = remoteFile(path: "/home/removed.txt", kind: .file)
let client = SnapshotSFTPClient(
responses: [.success([retained, removed]), .success([retained])]
)
let sftpSession = SFTPSessionModel(
hostID: UUID(), initialPath: "/home", client: client,
configuration: testConfiguration
)
await sftpSession.reload(silently: true)
sftpSession.selection = [retained.id, removed.id]
await sftpSession.reload(silently: true)
#expect(sftpSession.files == [retained])
#expect(sftpSession.selection == [retained.id])
}
@Test
func filteringPrunesSelectionThatIsNotRepresentedByTheTable() async {
let visible = remoteFile(path: "/home/visible.txt", kind: .file)
let hidden = remoteFile(path: "/home/hidden.log", kind: .file)
let client = SnapshotSFTPClient(responses: [.success([visible, hidden])])
let sftpSession = SFTPSessionModel(
hostID: UUID(), initialPath: "/home", client: client,
configuration: testConfiguration
)
await sftpSession.reload(silently: true)
sftpSession.selection = [hidden.id]
sftpSession.query = "visible"
#expect(sftpSession.filteredFiles == [visible])
#expect(sftpSession.selection.isEmpty)
}
@Test
func automaticRefreshRepeatsUntilDisconnect() async {
let client = SnapshotSFTPClient(responses: [.success([])])