Fix macOS SFTP context menu flicker
This commit is contained in:
1 parent
efbf3e3a54
commit
9149a9d0c9
8 files changed
+398
-111
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user