From 4998f12372257d5b59a78bb9f6e667a67321c26a Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 25 Aug 2026 07:52:10 +0800 Subject: [PATCH] Fix SFTP table AttributeGraph crash --- Termind/Features/SFTP/SFTPBrowserView.swift | 13 +++- Termind/Features/SFTP/SFTPSessionModel.swift | 62 +++++++++++++++++--- TermindTests/SFTPFeatureTests.swift | 38 ++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index 840ab989..70392986 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -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( diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 7cfc4317..601f75bb 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -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 = [] + 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 = [] - var query = "" - private(set) var files: [RemoteFile] = [] + private var directoryPresentation = SFTPDirectoryPresentation() + var selection: Set { + 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 diff --git a/TermindTests/SFTPFeatureTests.swift b/TermindTests/SFTPFeatureTests.swift index 496dc998..73b2d344 100644 --- a/TermindTests/SFTPFeatureTests.swift +++ b/TermindTests/SFTPFeatureTests.swift @@ -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([])])