Fix macOS SFTP context menu flicker

This commit is contained in:
Kaede Akino committed 2026-09-18 13:45:22 +08:00
1 parent efbf3e3a54
commit 9149a9d0c9
8 files changed
+398 -111

No files matched your search

+5
View File
@@ -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
+35 -64
View File
@@ -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)
}
}
+32 -40
View File
@@ -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) }
}
}
+13
View File
@@ -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 {
+31 -7
View File
@@ -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) }
+167
View File
@@ -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
}
}
+27
View File
@@ -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")