File ▸ Share… stages a board as a zip and hands it to the system share sheet
Duplicate's own posture — a faithful copy, `.git` the sole exclusion, attachments/comments/trash carried verbatim — staged to a temp directory (BoardShareStager, ditto-zipped via DittoZipArchiver) and presented through NSSharingServicePicker (BoardSharePresentation), anchored to the board window's toolbar or its center. Follows Duplicate/Save as Template's flush-then- cancellable-copy sequence under the banner's in-progress row (ShareBoardCommand, AppCommands.swift), menu-validated on focus alone rather than the read-only lock (a share is a read, Print's own posture) with the one carve-out an open inline title editor still needs. WriteOperation gains .shareBoard for the banner vocabulary; 11-command-nexus.md's File menu table gains the row. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import AppKit
|
||||
|
||||
/// File ▸ Share…'s AppKit half — `NSSharingServicePicker`, shown over the staged `.zip`
|
||||
/// (`BoardShareStager`) and torn down once its interaction has genuinely finished (design ruling
|
||||
/// 2026-08-09, card 72691b11).
|
||||
///
|
||||
/// **Deliberately untested**, `PrintCommand`'s own line drawn in the same place: "The picker
|
||||
/// presentation itself stays untested (AppKit boundary, consistent with Print's own boundary)".
|
||||
/// What is tested is everything on the other side of that boundary — `BoardShareStager`'s staging
|
||||
/// (pure `FileManager`/`Process` work) and `ShareBoardCommand.isEnabled` (a pure function of three
|
||||
/// facts) — so this file is deliberately thin: it owns exactly the two things that need `NSApp` and
|
||||
/// a live window to exist at all, presentation and cleanup timing, and nothing else.
|
||||
///
|
||||
/// ### Anchor: the toolbar, or the window's center
|
||||
///
|
||||
/// The ruling's own words: "anchored to the board window's toolbar (or window center fallback)".
|
||||
/// AppKit does not expose a public way to fetch a *specific* `NSToolbarItem`'s view for a
|
||||
/// customizable toolbar whose items the user may have rearranged or removed (`WindowToolbarController`
|
||||
/// builds items from a user-editable configuration; there is no "the Share button" to point at,
|
||||
/// because the ruling asks for a menu row, not a toolbar item) — so the closest honest reading of
|
||||
/// "anchored to the toolbar" reachable through public API is the content view's top-trailing
|
||||
/// corner, immediately below wherever the toolbar strip is drawn, when a toolbar is showing at all.
|
||||
/// **Flagged for owner review** (the card's DECISIONS comment): a real toolbar-item anchor would
|
||||
/// need a dedicated Share toolbar button, which the ruling's Surface bullet does not ask for.
|
||||
@MainActor
|
||||
enum BoardShareAnchor {
|
||||
|
||||
/// The view and rect `NSSharingServicePicker.show(relativeTo:of:preferredEdge:)` wants —
|
||||
/// window center whenever there is no window or no visible toolbar to approximate.
|
||||
static func anchor(in window: NSWindow?) -> (view: NSView, rect: NSRect, edge: NSRectEdge) {
|
||||
guard let window, let contentView = window.contentView else {
|
||||
// A command validated against the focus system (`ShareBoardCommand` requires a
|
||||
// focused board window) should never actually reach this — pure defensive fallback so
|
||||
// the picker still has *some* view to anchor to rather than a crash.
|
||||
let view = NSView()
|
||||
return (view, view.bounds, .minY)
|
||||
}
|
||||
|
||||
let bounds = contentView.bounds
|
||||
guard window.toolbar?.isVisible == true else {
|
||||
let center = NSRect(x: bounds.midX, y: bounds.midY, width: 1, height: 1)
|
||||
return (contentView, center, .minY)
|
||||
}
|
||||
|
||||
let corner = NSRect(x: bounds.maxX - 1, y: bounds.maxY - 1, width: 1, height: 1)
|
||||
return (contentView, corner, .maxY)
|
||||
}
|
||||
}
|
||||
|
||||
/// One picker, one staged archive, one cleanup — self-retained until the interaction is over,
|
||||
/// `PrintCommand.swift`'s `PrintCompletion` doing the identical trick for the identical reason:
|
||||
/// `NSSharingServicePicker` (like `NSPrintOperation`) holds its delegate weakly, and the object
|
||||
/// presenting it is otherwise a local that would deallocate the instant `present(anchorWindow:)`
|
||||
/// returns, before the user has clicked anything in the popover it just opened.
|
||||
///
|
||||
/// ### When the cleanup actually runs
|
||||
///
|
||||
/// Three terminal states, and cleanup on all three — the ruling's "removed after the picker
|
||||
/// dismisses or errors", read as *the interaction concluding* rather than only the popover
|
||||
/// closing, because a chosen service's own work (Mail composing a message, AirDrop's transfer) can
|
||||
/// genuinely outlive the small popover UI disappearing:
|
||||
///
|
||||
/// - **Dismissed without choosing** (`didChoose: nil`) — nothing will ever read the file; clean up
|
||||
/// immediately.
|
||||
/// - **The chosen service failed** (`didFailToShareItems`) — nothing shared; clean up.
|
||||
/// - **The chosen service succeeded** (`didShareItems`) — the recipient side has what it needs
|
||||
/// (Mail copied the attachment into the draft, AirDrop's transfer completed); clean up.
|
||||
///
|
||||
/// `sharingServicePicker(_:delegateFor:)` — not setting `service.delegate` inside `didChoose` — is
|
||||
/// the documented hook for supplying the delegate the picker's own internal `perform()` call will
|
||||
/// use, and it is what makes the two service callbacks below reachable at all.
|
||||
// `NSSharingServicePickerDelegate`/`NSSharingServiceDelegate` are not themselves `@MainActor` in
|
||||
// the SDK overlay (unlike `NSWindowDelegate`, which `HostedWindowController` conforms to with no
|
||||
// such note) — the conformance is isolated explicitly here, which is safe by construction: AppKit
|
||||
// invokes every one of these callbacks on the main thread, they touch nothing but this object's own
|
||||
// `archive`/`untilTheInteractionEnds`, and `present(anchorWindow:)` is only ever called from the
|
||||
// share command's own `@MainActor` task.
|
||||
@MainActor
|
||||
final class BoardSharePresentation: NSObject, @MainActor NSSharingServicePickerDelegate, @MainActor NSSharingServiceDelegate {
|
||||
|
||||
private let archive: BoardShareStager.StagedArchive
|
||||
private var untilTheInteractionEnds: BoardSharePresentation?
|
||||
|
||||
init(archive: BoardShareStager.StagedArchive) {
|
||||
self.archive = archive
|
||||
super.init()
|
||||
untilTheInteractionEnds = self
|
||||
}
|
||||
|
||||
/// Shows the picker over `archive.zipURL`, anchored per `BoardShareAnchor`.
|
||||
func present(anchorWindow: NSWindow?) {
|
||||
let picker = NSSharingServicePicker(items: [archive.zipURL])
|
||||
picker.delegate = self
|
||||
let (view, rect, edge) = BoardShareAnchor.anchor(in: anchorWindow)
|
||||
picker.show(relativeTo: rect, of: view, preferredEdge: edge)
|
||||
}
|
||||
|
||||
// MARK: - NSSharingServicePickerDelegate
|
||||
|
||||
func sharingServicePicker(
|
||||
_ sharingServicePicker: NSSharingServicePicker,
|
||||
didChoose service: NSSharingService?
|
||||
) {
|
||||
guard service != nil else {
|
||||
// The user closed the popover without picking anything — nothing is going to read the
|
||||
// staged file from here on.
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
// Left non-nil deliberately: `delegateFor:` below is what actually wires the completion
|
||||
// callbacks onto the service the picker is about to perform.
|
||||
}
|
||||
|
||||
func sharingServicePicker(
|
||||
_ sharingServicePicker: NSSharingServicePicker,
|
||||
delegateFor sharingService: NSSharingService
|
||||
) -> (any NSSharingServiceDelegate)? {
|
||||
self
|
||||
}
|
||||
|
||||
// MARK: - NSSharingServiceDelegate
|
||||
|
||||
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: any Error) {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
BoardShareStager.cleanup(archive)
|
||||
untilTheInteractionEnds = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user