diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 5dad1001..62a51738 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -241,6 +241,11 @@ copy also suppresses the conflicting root itself. Both use the same subtree mapp Keep Both naming, cancellation checkpoints, and recursive child-name/path validation; created-directory accounting remains available to awaited copy outcomes. +macOS SFTP file-row context menus are AppKit snapshots built on the context click, +after reconciling selection. Their targets and native menu items survive SwiftUI +row reconfiguration from hover, focus, selection, and directory refreshes. Menu +presentation never mutates selection from a SwiftUI appearance callback. + SFTP external edits have one process-local Application model per app, independent of browser/workspace lifetime. Remote Infrastructure owns managed local copies, streamed SHA-256 checks, file/directory observation (including atomic-save replacement), and diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index bcedde1c..4883e5c3 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -191,6 +191,7 @@ struct SFTPBrowserView: View { let closePane: () -> Void @Environment(AppModel.self) private var app + @Environment(\.locale) private var locale @Environment(WindowModel.self) private var window @Environment(\.controlActiveState) private var controlActiveState @State private var newFolderPresented = false @@ -871,9 +872,14 @@ struct SFTPBrowserView: View { } .onHover { hovering in hoveredFileID = hovering ? file.id : nil } .onDrag { remoteDragProvider(for: file) } - .contextMenu { - Group { fileMenu(file) } - .onAppear { selectForContextMenu(file) } + .overlay { + NativeRowContextMenu( + entries: { + selectForContextMenu(file) + return fileMenu(file) + }, + isPresented: .constant(false) + ) } .accessibilityElement(children: .combine) .accessibilityAddTraits(selected ? [.isSelected, .isButton] : .isButton) @@ -963,66 +969,35 @@ struct SFTPBrowserView: View { } } - @ViewBuilder - private func fileMenu(_ file: RemoteFile) -> some View { + private func fileMenu(_ file: RemoteFile) -> [NativeMenuEntry] { + // Capture targets once, before AppKit starts tracking the menu. Later + // selection, hover and directory updates must not retarget its actions. let targets = menuTargets(for: file) - if targets.count == 1 { - Button("Open", systemImage: MenuSymbol.open) { primaryAction(file) } - if !file.isDirectory { - SFTPOpenWithMenu(filename: file.name) { application in - open(file, with: application) - } - .disabled(sftpSession.status != .connected) - } - } - Button( - targets.count == 1 ? "Download" : "Download \(targets.count) Items", - systemImage: MenuSymbol.download - ) { - chooseDownloadDestination(for: targets) - } - Divider() - Button( - targets.count == 1 ? "Copy" : "Copy \(targets.count) Items", - systemImage: MenuSymbol.copy - ) { - copyRemoteItems(targets) - } - // Copy As is Copy's own submenu, so its two children stay bare. - Menu("Copy As", systemImage: MenuSymbol.copy) { - Button(targets.count == 1 ? "Name" : "Names") { - copyToPasteboard(targets.map(\.name).joined(separator: "\n")) - } - Button(targets.count == 1 ? "Path" : "Paths") { - copyToPasteboard(targets.map(\.path).joined(separator: "\n")) - } - } - Button("Paste", systemImage: MenuSymbol.paste) { pasteRemoteItems() } - .disabled(sftpSession.status != .connected || !canPasteRemoteItems) - if targets.count == 1 { - Button("Rename", systemImage: MenuSymbol.rename) { + return SFTPFileContextMenu( + file: file, targets: targets, + connected: sftpSession.status == .connected, + allowsRemoteMutations: sftpSession.allowsRemoteMutations, + canPaste: canPasteRemoteItems, isLoading: sftpSession.isLoading, + locale: locale + ).entries { action in + switch action { + case .open: primaryAction(file) + case .openWith(let application): open(file, with: application) + case .download: chooseDownloadDestination(for: targets) + case .copy: copyRemoteItems(targets) + case .copyNames: copyToPasteboard(targets.map(\.name).joined(separator: "\n")) + case .copyPaths: copyToPasteboard(targets.map(\.path).joined(separator: "\n")) + case .paste: pasteRemoteItems() + case .rename: renameTarget = file renameName = file.name - } - .disabled(!sftpSession.allowsRemoteMutations) - if file.kind == .file || file.kind == .directory { - Button("Edit Permissions", systemImage: MenuSymbol.editPermissions) { - permissionTarget = file - } - .disabled(!sftpSession.allowsRemoteMutations) + case .editPermissions: permissionTarget = file + case .delete: deleteTargets = targets + case .newFolder: newFolderPresented = true + case .upload: chooseUploadItems() + case .refresh: sftpSession.reloadNow() } } - DestructiveMenuButton(targets.count == 1 ? "Delete" : "Delete \(targets.count) Items") { - deleteTargets = targets - } - .disabled(!sftpSession.allowsRemoteMutations) - Divider() - Button("New Folder", systemImage: MenuSymbol.newFolder) { newFolderPresented = true } - .disabled(sftpSession.status != .connected) - Button("Upload", systemImage: MenuSymbol.upload) { chooseUploadItems() } - .disabled(sftpSession.status != .connected) - Button("Refresh", systemImage: MenuSymbol.refresh) { sftpSession.reloadNow() } - .disabled(sftpSession.status != .connected || sftpSession.isLoading) } private func primaryAction(_ file: RemoteFile) { @@ -1049,10 +1024,7 @@ struct SFTPBrowserView: View { } private func menuTargets(for file: RemoteFile) -> [RemoteFile] { - guard sftpSession.selection.contains(file.id), sftpSession.selection.count > 1 else { - return [file] - } - return sftpSession.selectedFiles + sftpSession.menuTargets(for: file) } private func copyToPasteboard(_ value: String) { @@ -1094,8 +1066,7 @@ struct SFTPBrowserView: View { } private func selectForContextMenu(_ file: RemoteFile) { - guard !sftpSession.selection.contains(file.id) else { return } - sftpSession.selection = [file.id] + guard sftpSession.selectForContextMenu(file) else { return } selectionAnchor = file.id selectionCursor = file.id focusListForUserGesture() diff --git a/TFM/Features/SFTP/SFTPFileContextMenu.swift b/TFM/Features/SFTP/SFTPFileContextMenu.swift new file mode 100644 index 00000000..aed61121 --- /dev/null +++ b/TFM/Features/SFTP/SFTPFileContextMenu.swift @@ -0,0 +1,88 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// A menu snapshot, built only in response to a context click, never during +/// SwiftUI row rendering. Actions retain the caller's corresponding targets. +@MainActor +struct SFTPFileContextMenu { + enum Action: Equatable { + case open + case openWith(URL) + case download, copy, copyNames, copyPaths, paste + case rename, editPermissions, delete, newFolder, upload, refresh + } + + let file: RemoteFile + let targets: [RemoteFile] + let connected: Bool + let allowsRemoteMutations: Bool + let canPaste: Bool + let isLoading: Bool + let locale: Locale + + func entries( + applications: @MainActor (String) -> [SFTPFileApplication] = SFTPApplicationOpener.applications(for:), + perform: @escaping (Action) -> Void + ) -> [NativeMenuEntry] { + let single = targets.count == 1 + var result: [NativeMenuEntry] = [] + if single { + result.append(entry("Open", symbol: MenuSymbol.open) { perform(.open) }) + if !file.isDirectory { + var openWith = SFTPOpenWithMenu(filename: file.name, locale: locale) { + perform(.openWith($0)) + }.entry(applications: applications(file.name)) + openWith.isEnabled = connected + result.append(openWith) + } + } + result.append( + entry(single ? "Download" : "Download \(targets.count) Items", symbol: MenuSymbol.download) { + perform(.download) + }) + result.append(.separator) + result.append( + entry(single ? "Copy" : "Copy \(targets.count) Items", symbol: MenuSymbol.copy) { perform(.copy) }) + result.append( + NativeMenuEntry( + title: localized("Copy As"), symbol: MenuSymbol.copy, + children: [ + entry(single ? "Name" : "Names") { perform(.copyNames) }, + entry(single ? "Path" : "Paths") { perform(.copyPaths) }, + ])) + result.append(entry("Paste", symbol: MenuSymbol.paste, enabled: connected && canPaste) { perform(.paste) }) + if single { + result.append( + entry("Rename", symbol: MenuSymbol.rename, enabled: allowsRemoteMutations) { perform(.rename) }) + if file.kind == .file || file.kind == .directory { + result.append( + entry("Edit Permissions", symbol: MenuSymbol.editPermissions, enabled: allowsRemoteMutations) { + perform(.editPermissions) + }) + } + } + result.append( + entry( + single ? "Delete" : "Delete \(targets.count) Items", symbol: MenuSymbol.delete, + enabled: allowsRemoteMutations + ) { perform(.delete) }) + result.append(.separator) + result.append(entry("New Folder", symbol: MenuSymbol.newFolder, enabled: connected) { perform(.newFolder) }) + result.append(entry("Upload", symbol: MenuSymbol.upload, enabled: connected) { perform(.upload) }) + result.append( + entry("Refresh", symbol: MenuSymbol.refresh, enabled: connected && !isLoading) { perform(.refresh) }) + return result + } + + private func localized(_ title: String.LocalizationValue) -> String { + AppLocalization.string(title, locale: locale) + } + + private func entry( + _ title: String.LocalizationValue, symbol: String? = nil, enabled: Bool = true, + action: @escaping () -> Void + ) -> NativeMenuEntry { + NativeMenuEntry(title: localized(title), symbol: symbol, isEnabled: enabled, action: action) + } +} diff --git a/TFM/Features/SFTP/SFTPOpenWithMenu.swift b/TFM/Features/SFTP/SFTPOpenWithMenu.swift index 08a648a9..4f5c37ee 100644 --- a/TFM/Features/SFTP/SFTPOpenWithMenu.swift +++ b/TFM/Features/SFTP/SFTPOpenWithMenu.swift @@ -1,51 +1,43 @@ -import SwiftUI +import AppKit +import TermindApplication -struct SFTPOpenWithMenu: View { +@MainActor +struct SFTPOpenWithMenu { let filename: String + let locale: Locale let open: (URL) -> Void - var body: some View { - Menu("Open With") { - let applications = SFTPApplicationOpener.applications(for: filename) - if let defaultApplication = applications.first(where: \.isDefault) { - applicationButton(defaultApplication) - Divider() - } - let otherApplications = applications.filter { !$0.isDefault } - ForEach(otherApplications) { application in - applicationButton(application) - } - if !otherApplications.isEmpty { Divider() } - Button("App Store…") { - SFTPApplicationOpener.openAppStore(for: filename) - } - .disabled(SFTPApplicationOpener.appStoreSearchURL(for: filename) == nil) - Button("Other…") { + func entry(applications: [SFTPFileApplication]) -> NativeMenuEntry { + var children: [NativeMenuEntry] = [] + if let defaultApplication = applications.first(where: \.isDefault) { + children.append(applicationEntry(defaultApplication)) + children.append(.separator) + } + let otherApplications = applications.filter { !$0.isDefault } + children += otherApplications.map(applicationEntry) + if !otherApplications.isEmpty { children.append(.separator) } + children.append( + NativeMenuEntry( + title: AppLocalization.string("App Store…", locale: locale), + isEnabled: SFTPApplicationOpener.appStoreSearchURL(for: filename) != nil, + action: { SFTPApplicationOpener.openAppStore(for: filename) } + )) + children.append( + NativeMenuEntry(title: AppLocalization.string("Other…", locale: locale)) { Task { if let application = await SFTPApplicationOpener.chooseApplication() { open(application) } } - } - } + }) + return NativeMenuEntry(title: AppLocalization.string("Open With", locale: locale), children: children) } - private func applicationButton(_ application: SFTPFileApplication) -> some View { - Button { - open(application.url) - } label: { - if let icon = application.icon { - Label { - Text(verbatim: title(for: application)) - } icon: { - Image(nsImage: icon) - } - } else { - Text(verbatim: title(for: application)) - } - } - } - - private func title(for application: SFTPFileApplication) -> String { - guard application.isDefault else { return application.name } - return String.localizedStringWithFormat(String(localized: "%@ (default)"), application.name) + private func applicationEntry(_ application: SFTPFileApplication) -> NativeMenuEntry { + let title = + application.isDefault + ? String( + format: AppLocalization.string("%@ (default)", locale: locale), + locale: locale, application.name) + : application.name + return NativeMenuEntry(title: title, image: application.icon) { open(application.url) } } } diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index 5ac1a573..ccd8d7e6 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -226,6 +226,19 @@ final class SFTPSessionModel: Identifiable { directoryPresentation.visibleRows.filter { selection.contains($0.id) }.map(\.file) } + /// Returns whether a new single selection was made. The view owns the + /// corresponding keyboard anchor and focus changes. + func selectForContextMenu(_ file: RemoteFile) -> Bool { + guard !selection.contains(file.id) else { return false } + selection = [file.id] + return true + } + + func menuTargets(for file: RemoteFile) -> [RemoteFile] { + guard selection.contains(file.id), selection.count > 1 else { return [file] } + return selectedFiles + } + func sort(by field: RemoteFileSortField) { var presentation = directoryPresentation if presentation.sortField == field { diff --git a/TFM/UI/Components/NativeContextMenu.swift b/TFM/UI/Components/NativeContextMenu.swift index 6a4f0783..d4778988 100644 --- a/TFM/UI/Components/NativeContextMenu.swift +++ b/TFM/UI/Components/NativeContextMenu.swift @@ -40,6 +40,7 @@ struct NativeMenuEntry { /// The SF Symbol beside the title, from `MenuSymbol`. AppKit fills this in /// for standard actions and leaves ours empty, so every verb names its own. var symbol: String? + var image: NSImage? var isOn = false var isEnabled = true var children: [NativeMenuEntry] = [] @@ -70,9 +71,11 @@ struct NativeMenuEntry { func menuItem() -> NSMenuItem { guard !isSeparator else { return .separator() } let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") - item.image = symbol.flatMap { - NSImage(systemSymbolName: $0, accessibilityDescription: nil) - } + item.image = + image + ?? symbol.flatMap { + NSImage(systemSymbolName: $0, accessibilityDescription: nil) + } if children.isEmpty { let target = NativeMenuActionTarget(action) item.representedObject = target @@ -130,18 +133,39 @@ struct NativeRowContextMenu: NSViewRepresentable { var setPresented: (Bool) -> Void = { _ in } override func hitTest(_ point: NSPoint) -> NSView? { - guard let event = NSApp.currentEvent, - event.type == .rightMouseDown || event.type == .rightMouseUp + guard let event = NSApp.currentEvent, Self.isContextMenuEvent(event) else { return nil } return super.hitTest(point) == nil ? nil : self } + static func isContextMenuEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .rightMouseDown, .rightMouseUp: true + case .leftMouseDown, .leftMouseUp: event.modifierFlags.contains(.control) + default: false + } + } + + override func mouseDown(with event: NSEvent) { + if Self.isContextMenuEvent(event) { + rightMouseDown(with: event) + } else { + super.mouseDown(with: event) + } + } + override func rightMouseDown(with event: NSEvent) { + guard let menu = makeMenu() else { return } + // Keep this menu independent of subsequent SwiftUI row updates. + NSMenu.popUpContextMenu(menu, with: event, for: self) + } + + func makeMenu() -> NSMenu? { let items = entries() - guard !items.isEmpty else { return } + guard !items.isEmpty else { return nil } let menu = NativeMenuEntry.menu(items) menu.delegate = self - NSMenu.popUpContextMenu(menu, with: event, for: self) + return menu } func menuWillOpen(_ menu: NSMenu) { setPresented(true) } diff --git a/TFMTests/SFTPContextMenuTests.swift b/TFMTests/SFTPContextMenuTests.swift new file mode 100644 index 00000000..e2ec74c3 --- /dev/null +++ b/TFMTests/SFTPContextMenuTests.swift @@ -0,0 +1,167 @@ +import AppKit +import SwiftUI +import TermindDomain +import Testing + +@testable import Termind + +@Suite(.serialized) @MainActor +struct SFTPContextMenuTests { + @Test func singleFileKeepsItsActionsAndOpenWithApplicationIcons() throws { + let file = file("config.json") + let icon = NSImage(size: NSSize(width: 16, height: 16)) + let application = SFTPFileApplication( + url: URL(filePath: "/Applications/Editor.app"), name: "Editor", icon: icon, isDefault: true) + var actions: [SFTPFileContextMenu.Action] = [] + let entries = snapshot(file).entries(applications: { _ in [application] }, perform: { actions.append($0) }) + let menu = NativeMenuEntry.menu(entries) + #expect( + menu.items.map(\.title) == [ + "Open", "Open With", "Download", "", "Copy", "Copy As", "Paste", "Rename", + "Edit Permissions", "Delete", "", "New Folder", "Upload", "Refresh", + ]) + #expect(!menu.autoenablesItems) + let openWith = try #require(menu.items[1].submenu) + #expect(openWith.items.map(\.title) == ["Editor (default)", "", "App Store…", "Other…"]) + #expect(openWith.items[0].image === icon) + openWith.performActionForItem(at: 0) + menu.performActionForItem(at: 9) + #expect(actions == [.openWith(application.url), .delete]) + #expect(menu.items[9].image != nil) + let copyAs = try #require(menu.items[5].submenu) + #expect(copyAs.items.map(\.title) == ["Name", "Path"]) + #expect(copyAs.items.allSatisfy { $0.image == nil }) + } + + @Test func directoriesAndMultipleSelectionsDoNotDiscoverApplications() { + let directory = file("folder", kind: .directory) + let first = file("a.txt") + let second = file("b.txt") + var discoveries = 0 + let applications: @MainActor (String) -> [SFTPFileApplication] = { _ in + discoveries += 1 + return [] + } + let folderMenu = snapshot(directory).entries(applications: applications) { _ in } + #expect(folderMenu.contains { $0.title == "Open" }) + #expect(!folderMenu.contains { $0.title == "Open With" }) + #expect(folderMenu.contains { $0.title == "Edit Permissions" }) + let multiple = snapshot(first, targets: [first, second]).entries(applications: applications) { _ in } + #expect( + multiple.map(\.title) == [ + "Download 2 Items", "", "Copy 2 Items", "Copy As", "Paste", "Delete 2 Items", "", + "New Folder", "Upload", "Refresh", + ]) + #expect(multiple[3].children.map(\.title) == ["Names", "Paths"]) + #expect(discoveries == 0) + } + + @Test func connectionClipboardAndRefreshStateKeepTheirDisabledActions() throws { + let disconnected = snapshot(file("README"), connected: false) + .entries(applications: { _ in [] }, perform: { _ in }) + for title in ["Open With", "Paste", "Rename", "Edit Permissions", "Delete", "New Folder", "Upload", "Refresh"] { + #expect(try #require(disconnected.first { $0.title == title }).isEnabled == false) + } + let openWith = try #require(disconnected.first { $0.title == "Open With" }) + #expect(openWith.children.map(\.title) == ["App Store…", "Other…"]) + #expect(!openWith.children[0].isEnabled) + #expect(openWith.children[1].isEnabled) + let loading = snapshot(file("a.txt"), canPaste: false, isLoading: true) + .entries(applications: { _ in [] }, perform: { _ in }) + #expect(try #require(loading.first { $0.title == "Paste" }).isEnabled == false) + #expect(try #require(loading.first { $0.title == "Refresh" }).isEnabled == false) + #expect(try #require(loading.first { $0.title == "Rename" }).isEnabled) + } + + @Test(arguments: [ + ("en", "Download 2 Items", "Copy 2 Items", "Delete 2 Items"), + ("zh-Hans", "下载2项", "复制2项", "删除2项"), + ("zh-Hant", "下載2項", "複製2項", "刪除2項"), + ]) + func countedTitlesFollowTheAppLocale(_ language: String, _ download: String, _ copy: String, _ delete: String) { + let first = file("a.txt") + let menu = snapshot(first, targets: [first, file("b.txt")], locale: language) + .entries(applications: { _ in [] }, perform: { _ in }) + #expect(menu[0].title == download) + #expect(menu[2].title == copy) + #expect(menu[5].title == delete) + } + + @Test func rowReconfigurationDoesNotRebuildOrRetargetAnExistingMenu() throws { + var target = "a.txt" + var menuBuilds = 0 + var performed: [String] = [] + let row = HostingListRow(frame: CGRect(x: 0, y: 0, width: 400, height: 48)) + func content() -> AnyView { + AnyView( + Text(target).frame(width: 400, height: 48) + .overlay { + NativeRowContextMenu( + entries: { + menuBuilds += 1 + let snapshot = target + return [NativeMenuEntry(title: snapshot) { performed.append(snapshot) }] + }, isPresented: .constant(false)) + }) + } + row.configure(rootView: content()) + row.layoutSubtreeIfNeeded() + let source = try #require(findMenuView(row)) + let menu = try #require(source.makeMenu()) + let item = try #require(menu.items.first) + + target = "b.txt" + row.configure(rootView: content()) + row.layoutSubtreeIfNeeded() + #expect(menuBuilds == 1) + #expect(menu.items.first === item) + #expect(item.title == "a.txt") + menu.performActionForItem(at: 0) + #expect(performed == ["a.txt"]) + + let nextMenu = try #require(findMenuView(row)?.makeMenu()) + #expect(nextMenu !== menu) + #expect(nextMenu.items.first?.title == "b.txt") + #expect(menuBuilds == 2) + row.prepareForReuse() + nextMenu.performActionForItem(at: 0) + #expect(performed == ["a.txt", "b.txt"]) + } + + @Test func onlyContextClicksAreClaimedByTheMenuOverlay() throws { + let cases: [(NSEvent.EventType, NSEvent.ModifierFlags, Bool)] = [ + (.rightMouseDown, [], true), (.rightMouseUp, [], true), + (.leftMouseDown, .control, true), (.leftMouseUp, .control, true), + (.leftMouseDown, [], false), (.leftMouseUp, [], false), + (.leftMouseDown, .command, false), (.leftMouseDown, .shift, false), + (.leftMouseDragged, [], false), (.mouseMoved, [], false), + ] + for (type, modifiers, expected) in cases { + let event = try #require( + NSEvent.mouseEvent( + with: type, location: .zero, modifierFlags: modifiers, timestamp: 0, + windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 0)) + #expect(NativeRowContextMenu.RowMenuView.isContextMenuEvent(event) == expected) + } + } + + private func file(_ name: String, kind: RemoteFile.Kind = .file) -> RemoteFile { + RemoteFile( + path: "/home/\(name)", name: name, kind: kind, + size: 10, permissions: 0o644, ownerUID: 0, ownerGID: 0) + } + + private func snapshot( + _ file: RemoteFile, targets: [RemoteFile]? = nil, connected: Bool = true, + canPaste: Bool = true, isLoading: Bool = false, locale: String = "en" + ) -> SFTPFileContextMenu { + SFTPFileContextMenu( + file: file, targets: targets ?? [file], connected: connected, allowsRemoteMutations: connected, + canPaste: canPaste, isLoading: isLoading, locale: Locale(identifier: locale)) + } + + private func findMenuView(_ view: NSView) -> NativeRowContextMenu.RowMenuView? { + if let menu = view as? NativeRowContextMenu.RowMenuView { return menu } + return view.subviews.lazy.compactMap(findMenuView).first + } +} diff --git a/TFMTests/SFTPFeatureTests.swift b/TFMTests/SFTPFeatureTests.swift index bcb48e20..cb35befb 100644 --- a/TFMTests/SFTPFeatureTests.swift +++ b/TFMTests/SFTPFeatureTests.swift @@ -16,6 +16,33 @@ import struct TermindDomain.Host @Suite @MainActor struct SFTPFeatureTests { + @Test + func contextClickSelectsBeforeBuildingTargetsAndPreservesAnExistingMultiSelection() async { + let first = remoteFile(path: "/home/a.txt", kind: .file) + let second = remoteFile(path: "/home/b.txt", kind: .file) + let third = remoteFile(path: "/home/c.txt", kind: .file) + let session = SFTPSessionModel( + hostID: UUID(), initialPath: "/home", + client: SnapshotSFTPClient(responses: [.success([first, second, third])])) + await session.reload(silently: true) + + #expect(session.selectForContextMenu(first)) + #expect(session.selection == [first.id]) + #expect(session.menuTargets(for: first) == [first]) + + session.selection = [first.id, second.id] + #expect(!session.selectForContextMenu(second)) + #expect(session.selection == [first.id, second.id]) + let targets = session.menuTargets(for: second) + #expect(targets == [first, second]) + + #expect(session.selectForContextMenu(third)) + #expect(session.selection == [third.id]) + #expect(session.menuTargets(for: third) == [third]) + #expect(targets == [first, second]) + #expect(!session.selectForContextMenu(third)) + } + @Test func transferSummaryWeightsProgressByBytesAndCountsFailures() { let host = Host(name: "Host", host: "host.example")