feat: move drop toast to top-right and add cancel button

Two changes to the drop progress HUD:

  - Position: was bottom-center of the VM window, now top-right with a
    quick (~0.18 s) slide-in from the right edge — reads as a macOS
    notification banner. Subsequent files in a multi-file drop retarget
    the panel in place without replaying the animation.

  - Cancel: small ⊗ close button in the toast's top-right corner. Click
    flips a `DropCancellationToken` shared with the chunked copier,
    which polls between 1 MiB chunks and throws `DropCopyCancelled` on
    the next boundary (sub-100 ms latency on fast disks). The drop
    handler removes the half-copied destination, the toast shows
    "Cancelled", and any remaining files in a multi-file drop are
    skipped instead of starting.
This commit is contained in:
Dal Rupnik 2026-05-13 15:26:43 +02:00
parent 8c684f52e4
commit 83be453200
2 changed files with 192 additions and 51 deletions

View File

@ -1128,13 +1128,22 @@ class VMContainerView: NSView {
let controlSocketURL = MainApp.controlSocketURL
let parentWindow = self.window
// One cancellation token covers the whole drop gesture: clicking on
// the toast aborts the current file AND skips any remaining files in a
// multi-file drop.
let cancelToken = DropCancellationToken()
// Copy off the main thread: large files would otherwise freeze the VM
// window (which is the same view that's rendering the VM's framebuffer).
// The toast (HUD-style progress panel) anchors to `parentWindow` so the
// user sees a per-file progress bar instead of a frozen UI.
// user sees a per-file progress bar and a cancel button instead of
// a frozen UI.
let totalFiles = urls.count
copyQueue.async {
for (idx, url) in urls.enumerated() {
// Honor cancellation before starting the next file in a multi-drop.
if cancelToken.isCancelled { break }
let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent)
let totalBytes = ((try? FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
let fileIndex = idx + 1
@ -1145,12 +1154,18 @@ class VMContainerView: NSView {
filename: url.lastPathComponent,
totalBytes: totalBytes,
index: fileIndex,
count: totalFiles
count: totalFiles,
cancelToken: cancelToken
)
}
do {
try DropProgressCopier.copy(from: url, to: dest, totalBytes: totalBytes) { copied in
try DropProgressCopier.copy(
from: url,
to: dest,
totalBytes: totalBytes,
token: cancelToken
) { copied in
DispatchQueue.main.async {
DropProgressToast.shared.update(
copied: copied,
@ -1177,6 +1192,15 @@ class VMContainerView: NSView {
controlSocketURL: controlSocketURL
)
}
} catch is DropCopyCancelled {
// User clicked : remove the partial destination so the share
// folder doesn't accumulate half-copied junk, surface "Cancelled"
// on the toast, and skip the remaining files in this drop.
try? FileManager.default.removeItem(at: dest)
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false, cancelled: true)
}
break
} catch {
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: false)

View File

@ -1,21 +1,49 @@
import AppKit
import Foundation
/// Thread-safe cancellation flag for an in-flight hostguest copy. The toast
/// holds one of these and flips it when the user clicks the close button;
/// `DropProgressCopier` polls it between chunks and throws `DropCopyCancelled`.
final class DropCancellationToken {
private let lock = NSLock()
private var _cancelled = false
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return _cancelled
}
func cancel() {
lock.lock()
defer { lock.unlock() }
_cancelled = true
}
}
/// Sentinel thrown by `DropProgressCopier.copy` when the caller's token is
/// flipped mid-copy. Distinct from a real I/O failure so the drop handler can
/// suppress the "Failed to copy" alert in this case.
struct DropCopyCancelled: Error {}
/// Borderless HUD-style panel shown over the VM window during a hostguest
/// drag-and-drop copy. Displays the filename, a determinate progress bar, and
/// a "[i/N] copied / total" detail line; auto-hides shortly after `finish`.
/// drag-and-drop copy. Renders like a macOS notification banner: anchored to
/// the top-right of the VM window, slides in from the right edge, shows the
/// filename / determinate bar / "[i/N] copied / total" detail line, and has
/// a close () button that cancels the in-flight copy.
///
/// All methods MUST be called on the main thread. Callers driving copies from
/// a background queue should hop via `DispatchQueue.main.async` first.
/// All methods MUST be called on the main thread. Callers driving copies
/// from a background queue should hop via `DispatchQueue.main.async` first.
///
/// Lifecycle:
/// begin(...) -> panel appears as a child of the VM window
/// update(...) -> progress bar advances (no-op if not visible)
/// finish(success:) -> brief "Done" / "Failed" message, then auto-hide
/// Lifecycle per file:
/// begin(...) -> panel appears (or retargets) and slides
/// in if it wasn't already visible
/// update(...) -> bar advances; no-op if not visible
/// finish(success:cancelled:) -> brief "Done"/"Cancelled"/"Copy failed"
/// state, then auto-hide after ~0.8 s
///
/// Multiple files dropped in one gesture are copied serially by the caller;
/// `begin` may be invoked again before the previous `finish` has hidden the
/// panel that just retargets the existing panel to the new file.
/// Multiple files dropped in one gesture serially reuse the same panel and
/// increment the [i/N] counter without re-animating.
final class DropProgressToast {
static let shared = DropProgressToast()
@ -23,22 +51,42 @@ final class DropProgressToast {
private var progressBar: NSProgressIndicator!
private var titleLabel: NSTextField!
private var detailLabel: NSTextField!
private var cancelButton: NSButton!
private var hideWorkItem: DispatchWorkItem?
private weak var anchorWindow: NSWindow?
/// Cancellation token for the copy currently driving the toast. Cleared
/// once the user clicks or `finish` is called, so a late click after the
/// copy already completed does nothing.
private var currentToken: DropCancellationToken?
private init() {}
/// Show the toast (or retarget it to a new file) and reset the progress bar.
/// `parent` is the VM window; the panel becomes a child of it so it tracks
/// movement and z-order. `totalBytes <= 0` flips the bar to indeterminate.
func begin(parent: NSWindow?, filename: String, totalBytes: Int64, index: Int, count: Int) {
/// Show the toast (or retarget it to a new file) and reset the progress
/// bar. `parent` is the VM window; the panel becomes a child of it so it
/// tracks movement and z-order. `cancelToken` is what the close button
/// flips on click. `totalBytes <= 0` switches the bar to indeterminate.
func begin(
parent: NSWindow?,
filename: String,
totalBytes: Int64,
index: Int,
count: Int,
cancelToken: DropCancellationToken
) {
ensurePanel()
hideWorkItem?.cancel()
hideWorkItem = nil
let wasAlreadyVisible = (panel?.isVisible == true)
anchorWindow = parent
currentToken = cancelToken
titleLabel.stringValue = filename
detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count)
cancelButton.isHidden = false
cancelButton.isEnabled = true
if totalBytes > 0 {
progressBar.isIndeterminate = false
@ -50,18 +98,23 @@ final class DropProgressToast {
}
progressBar.startAnimation(nil)
positionPanel()
guard let panel = panel else { return }
if let parent = parent {
// addChildWindow re-parents harmlessly even if already attached.
// Re-parenting is harmless if we're already a child of `parent`.
parent.addChildWindow(panel, ordered: .above)
}
if wasAlreadyVisible {
// Subsequent files in a multi-file drop: just retarget the existing
// panel in place, don't replay the slide-in.
positionPanel(animated: false)
} else {
positionPanel(animated: true)
panel.orderFront(nil)
}
}
/// Update the progress bar and detail line. No-op if the panel isn't visible
/// (i.e. `begin` was never called or `finish` already hid it).
/// Update the progress bar and detail line. No-op if the panel isn't
/// visible (i.e. `begin` was never called or `finish` already hid it).
func update(copied: Int64, total: Int64, index: Int, count: Int) {
guard let panel = panel, panel.isVisible else { return }
if total > 0 {
@ -71,26 +124,39 @@ final class DropProgressToast {
detailLabel.stringValue = formatDetail(copied: copied, total: total, index: index, count: count)
}
/// Flash a "Done" or "Copy failed" state and schedule the panel to hide
/// after a short delay so the user sees the final state.
func finish(success: Bool) {
/// Flash a final state and schedule the panel to hide. Pass `cancelled:
/// true` when the copy ended because the user clicked ; that surfaces
/// "Cancelled" instead of "Copy failed".
func finish(success: Bool, cancelled: Bool = false) {
guard let panel = panel else { return }
if success {
progressBar.isIndeterminate = false
progressBar.doubleValue = progressBar.maxValue
detailLabel.stringValue = "Done"
} else if cancelled {
detailLabel.stringValue = "Cancelled"
} else {
detailLabel.stringValue = "Copy failed"
}
progressBar.stopAnimation(nil)
cancelButton.isEnabled = false
currentToken = nil
let work = DispatchWorkItem { [weak self] in
self?.hide()
}
hideWorkItem = work
// 0.8s lets the user register the final state without lingering forever.
// 0.8 s lets the user register the final state without lingering.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: work)
_ = panel // silence warning about unused panel
_ = panel
}
@objc private func cancelClicked() {
// Flip the token; the copy loop notices on its next chunk boundary and
// throws DropCopyCancelled, which the caller turns into finish(cancelled:).
currentToken?.cancel()
cancelButton.isEnabled = false
detailLabel.stringValue = "Cancelling…"
}
private func hide() {
@ -104,7 +170,7 @@ final class DropProgressToast {
private func ensurePanel() {
if panel != nil { return }
let contentRect = NSRect(x: 0, y: 0, width: 360, height: 78)
let contentRect = NSRect(x: 0, y: 0, width: 320, height: 78)
let p = NSPanel(
contentRect: contentRect,
styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView],
@ -120,7 +186,7 @@ final class DropProgressToast {
p.hasShadow = true
p.level = .floating
// Vibrant rounded background HUD-style.
// Vibrant rounded background HUD-style, matches notification banners.
let effect = NSVisualEffectView(frame: contentRect)
effect.material = .hudWindow
effect.blendingMode = .behindWindow
@ -139,6 +205,25 @@ final class DropProgressToast {
title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
effect.addSubview(title)
// Top-right close button. SF Symbol `xmark.circle.fill` in secondary
// label color so it reads as "dismiss" rather than competing with the
// filename for attention.
let btn = NSButton()
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imagePosition = .imageOnly
btn.imageScaling = .scaleProportionallyDown
btn.image = NSImage(
systemSymbolName: "xmark.circle.fill",
accessibilityDescription: "Cancel copy"
)
btn.contentTintColor = .secondaryLabelColor
btn.target = self
btn.action = #selector(cancelClicked)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.toolTip = "Cancel copy"
effect.addSubview(btn)
let bar = NSProgressIndicator()
bar.style = .bar
bar.isIndeterminate = false
@ -155,16 +240,25 @@ final class DropProgressToast {
effect.addSubview(detail)
NSLayoutConstraint.activate([
// Title spans from the left padding to just before the close button.
title.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 14),
title.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -14),
title.trailingAnchor.constraint(equalTo: btn.leadingAnchor, constant: -6),
title.topAnchor.constraint(equalTo: effect.topAnchor, constant: 10),
// Close button: 18x18, hugging the top-right corner.
btn.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -10),
btn.centerYAnchor.constraint(equalTo: title.centerYAnchor),
btn.widthAnchor.constraint(equalToConstant: 18),
btn.heightAnchor.constraint(equalToConstant: 18),
// Progress bar spans the full width below the title row.
bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
bar.trailingAnchor.constraint(equalTo: title.trailingAnchor),
bar.trailingAnchor.constraint(equalTo: btn.trailingAnchor),
bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8),
detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
// Detail line under the bar.
detail.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
detail.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
detail.topAnchor.constraint(equalTo: bar.bottomAnchor, constant: 6),
detail.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -10),
])
@ -173,20 +267,38 @@ final class DropProgressToast {
self.titleLabel = title
self.detailLabel = detail
self.progressBar = bar
self.cancelButton = btn
}
/// Center the panel along the VM window's bottom edge with a small inset.
/// Falls back to no-op if we don't have an anchor window.
private func positionPanel() {
/// Position the panel in the VM window's top-right corner with a small
/// inset. When `animated` is true (first appearance of a drop session),
/// the panel starts off-screen-right and slides into place over ~0.18 s,
/// mimicking macOS notification banners.
private func positionPanel(animated: Bool) {
guard let panel = panel, let parent = anchorWindow else { return }
let parentFrame = parent.frame
let size = panel.frame.size
let inset: CGFloat = 16
let origin = NSPoint(
x: parentFrame.midX - size.width / 2,
y: parentFrame.minY + inset
let rightInset: CGFloat = 16
// Clear the titlebar plus a bit of breathing room.
let topInset: CGFloat = 50
let target = NSPoint(
x: parentFrame.maxX - size.width - rightInset,
y: parentFrame.maxY - size.height - topInset
)
panel.setFrameOrigin(origin)
if animated {
// Start off-screen to the right, then slide in.
let start = NSPoint(x: parentFrame.maxX + 8, y: target.y)
panel.setFrameOrigin(start)
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.18
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
panel.animator().setFrameOrigin(target)
}
} else {
panel.setFrameOrigin(target)
}
}
private func formatDetail(copied: Int64, total: Int64, index: Int, count: Int) -> String {
@ -199,21 +311,24 @@ final class DropProgressToast {
}
}
/// Chunked file copy with throttled progress callbacks. Used by the drag-and-
/// drop handler to feed `DropProgressToast` without freezing the VM render
/// view (the previous synchronous `FileManager.copyItem` blocked the same
/// queue/view).
/// Chunked file copy with throttled progress callbacks and cancellation.
/// Used by the drag-and-drop handler to feed `DropProgressToast` without
/// freezing the VM render view.
///
/// - Removes any existing file at `dst` first (drops semantically replace).
/// - Polls `token.isCancelled` between chunks; throws `DropCopyCancelled`
/// immediately on cancel so the caller can clean up the partial file.
/// - Reports `progress(copied)` at most once every ~50 ms during the copy,
/// plus a final call at completion so the bar always reaches 100%.
/// - Throws if either side fails; partial output at `dst` is left in place
/// so the caller can decide how to surface the error.
/// - Throws on either side's I/O error; partial output at `dst` is left in
/// place so the caller can decide how to surface the error (delete +
/// alert, or leave it for the user).
enum DropProgressCopier {
static func copy(
from src: URL,
to dst: URL,
totalBytes: Int64,
token: DropCancellationToken,
progress: (Int64) -> Void
) throws {
_ = totalBytes // accepted for future use (ETA, average rate, etc.)
@ -236,13 +351,15 @@ enum DropProgressCopier {
try? output.close()
}
let chunkSize = 1 * 1024 * 1024 // 1 MiB large enough to amortize syscalls,
// small enough to stream progress on fast disks.
let chunkSize = 1 * 1024 * 1024 // 1 MiB amortizes syscalls, still
// streams progress and bounds cancellation latency on fast disks.
let reportInterval: TimeInterval = 0.05
var lastReport = Date(timeIntervalSince1970: 0)
var copied: Int64 = 0
while true {
if token.isCancelled { throw DropCopyCancelled() }
let chunk = input.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
try output.write(contentsOf: chunk)
@ -254,8 +371,8 @@ enum DropProgressCopier {
lastReport = now
}
}
// Always fire a final callback so the UI reaches 100% even when the file
// finished inside the throttle window.
// Always fire a final callback so the UI reaches 100% even when the
// file finished inside the throttle window.
progress(copied)
}
}