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:
2026-08-09 02:13:54 -04:00
parent 05bbf78926
commit da5d310673
10 changed files with 997 additions and 15 deletions
+147 -9
View File
@@ -168,7 +168,7 @@ struct DuplicateBoardCommand: View {
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
let cancellation = BoardCopyCancellation<URL>()
// The in-progress row 02 § The banner surface names for "big-board Duplicate": info
// tone, pinned, cleared on completion, swapped for the error row on failure and
// carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy,
@@ -225,7 +225,7 @@ struct DuplicateBoardCommand: View {
_ source: URL,
titled name: String,
into destination: URL?,
cancellation: DuplicateCancellation
cancellation: BoardCopyCancellation<URL>
) async -> Result<URL, BoardDuplicator.Failure> {
// Cancelled during the flush: the copy never starts, rather than starting and being told to
// stop the same outcome, reached without making a folder to delete.
@@ -379,7 +379,7 @@ struct SaveAsTemplateCommand: View {
let source = store.rootURL
Task { @MainActor in
let cancellation = DuplicateCancellation()
let cancellation = BoardCopyCancellation<URL>()
// Copy-shaped work, so the row carries Cancel "remove the partial copy, nothing lost"
// (02 § The banner surface), which the engine honors by removing the partial store entry.
let operation = store.banners.beginOperation(
@@ -434,9 +434,142 @@ struct SaveAsTemplateCommand: View {
}
}
/// The Cancel button's end of a running board copy File Duplicate's and File Save as
/// Template's alike: the one piece of state the banner row's `cancel` closure and the copy task have
/// to share.
// MARK: - Share
/// File Share the board staged into a `.zip` and handed to `NSSharingServicePicker`
/// (11-command-nexus.md; design ruling 2026-08-09, card 72691b11 "create a share sheet target for
/// board").
///
/// ### What it shares, and why a zip
///
/// A board is a folder tree, and no share destination (Mail, Messages, AirDrop) knows how to carry
/// one as an item the way it carries a file so this stages the board into a single `.zip`, named
/// `"<Board Title>.zip"`, and hands the picker that one URL (`BoardShareStager`). The content rule
/// is the ruling's own: **`.git` is the sole exclusion** inert history a recipient has no use for
/// and should not receive unasked and **everything else rides along verbatim: attachments,
/// comments, and `.trash/` included**, "a faithful copy" in the ruling's own words, echoing
/// `BoardDuplicator`'s posture rather than `TemplateEngine`'s narrower one. The trash inclusion is
/// flagged for owner review in the card's DECISIONS comment.
///
/// ### The sequence is Duplicate's, once more, with a picker where the open used to be
///
/// 1. **The flush first** `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own step:
/// "stage from a flushed state never share a half-written buffer" is the ruling's phrasing for
/// exactly what pending-work-landed-on-disk means.
/// 2. **The stage** `BoardShareStager.stage(boardAt:titled:)`, off the main actor so the banner's
/// in-progress row can spin, cancellable the same way (`BoardCopyCancellation`).
/// 3. **The picker** `BoardSharePresentation`, anchored to the window that was key when Share was
/// pressed (captured weakly before the flush's `await`, so a window closed mid-stage does not
/// keep it alive, and re-resolved to the current key window if it went away `PrintCoordinator
/// .keyWindow()`'s reasoning, stretched across the async gap staging needs that printing does
/// not). Presentation owns the staged files from there it is the one thing that knows when the
/// picker's interaction has actually finished and it is what removes them.
///
/// ### Validation
///
/// **Simpler than Duplicate's or Save as Template's**, by the ruling's own words ("enabled when a
/// board window is focused") and defensibly so: unlike either sibling, this never writes a byte
/// into the board's own tree, so the read-only lock's reason for existing (protecting board writes
/// a sandbox refusal or a vanished root would refuse) does not apply `PrintCommand`'s own "a
/// print is a read" posture, applied to a copy that leaves rather than one that prints. **The one
/// carve-out kept is the focused-inline-editor half**, not the whole of `acceptsBoardMutations`: an
/// open rename or new-card placeholder holds the one pending change no flush can reach
/// (`DuplicateBoardCommand`'s own note), and sharing mid-rename would zip a title the user is still
/// typing. Flagged for owner review the ruling's literal wording names only "board window
/// focused", not this carve-out.
struct ShareBoardCommand: View {
let appModel: AppModel
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardWindowRef) private var ref
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "share")
var body: some View {
Button("Share…") {
share()
}
.disabled(!canShare)
}
/// The row's whole decision, as a pure function of the three facts it turns on
/// (`SaveAsTemplateCommand.allowsSave`'s reason for extracting validation from the view: a rule
/// reachable only through a menu is a rule nobody tests).
static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool {
hasStore && hasRef && !isEditingInline
}
private var canShare: Bool {
Self.isEnabled(
hasStore: store != nil,
hasRef: ref != nil,
isEditingInline: store?.isEditingInline == true
)
}
private func share() {
guard canShare, let store, let ref else { return }
let name = AppModel.displayName(of: store)
let source = store.rootURL
Task { @MainActor in
// Weak: the window this share was invoked from must not be kept alive by this task
// across the flush's `await` just so the picker can anchor to it later.
weak var invokedWindow = NSApp.keyWindow ?? NSApp.mainWindow
let cancellation = BoardCopyCancellation<BoardShareStager.StagedArchive>()
let operation = store.banners.beginOperation(
label: "Preparing '\(name)' to share…",
cancel: { cancellation.cancel() }
)
defer { store.banners.endOperation(operation) }
await appModel.flushPendingWork(for: ref)
// Cancelled during the flush: staging never starts (`SaveAsTemplateCommand.save()`'s
// own guard, verbatim).
guard !cancellation.isCancelled else { return }
// Detached, `DuplicateBoardCommand`'s two-part reason: real I/O (a board's trash and
// attachments can be real bytes, and `ditto` zipping them is not instant) that must not
// block the banner's spinner, and a detached task's cancellation is only ever this
// row's Cancel.
let task = Task.detached(priority: .userInitiated) {
try BoardShareStager.stage(boardAt: source, titled: name)
}
cancellation.attach(task)
do {
let archive = try await task.value
let anchorWindow = invokedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
BoardSharePresentation(archive: archive).present(anchorWindow: anchorWindow)
} catch let failure as BoardShareStager.Failure {
switch failure {
case .cancelled:
// The walk removed its own partial staging directory; the row leaves with the
// `defer`.
Self.logger.notice("share cancelled — the staged copy was removed")
case let .failed(error):
Self.logger.error("share failed: \(error.description, privacy: .public)")
store.banners.post(error)
}
} catch {
let write = BoardWriteError(
operation: .shareBoard(title: name),
path: source.path,
reason: .io(message: error.localizedDescription)
)
Self.logger.error("share failed: \(write.description, privacy: .public)")
store.banners.post(write)
}
}
}
}
/// The Cancel button's end of a running board copy File Duplicate's, File Save as Template's
/// and File Share's alike: the one piece of state the banner row's `cancel` closure and the copy
/// task have to share.
///
/// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's
/// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The
@@ -444,13 +577,18 @@ struct SaveAsTemplateCommand: View {
/// sets by cancelling the task so this type exists only to close the window between the row
/// appearing and the copy task existing. A Cancel pressed during the flush must not be forgotten by
/// the task that starts after it, which is what `isCancelled` is for.
///
/// **Generic over the task's success type** (`URL` for Duplicate and Save as Template,
/// `BoardShareStager.StagedArchive` for Share) since it joined a third caller whose detached task
/// answers a different value the cancellation bookkeeping is identical either way and does not
/// care what the copy produced.
@MainActor
private final class DuplicateCancellation {
private final class BoardCopyCancellation<Success: Sendable> {
private var task: Task<URL, any Error>?
private var task: Task<Success, any Error>?
private(set) var isCancelled = false
func attach(_ task: Task<URL, any Error>) {
func attach(_ task: Task<Success, any Error>) {
self.task = task
if isCancelled { task.cancel() }
}