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:
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user