Fix SFTP table AttributeGraph crash
This commit is contained in:
1 parent
977dbb872e
commit
4998f12372
3 files changed
+102
-11
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])])
|
||||
|
||||
Reference in new issue
Block a user