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:
@@ -27,6 +27,7 @@ The single source of truth for **every command and action the app can perform**
|
||||
| File | Board Info (toggles the board popover — opens it closed, closes it open) | ⌘I | Board window |
|
||||
| File | Duplicate (the board — a Finder-style "copy" sibling, 03 ▸ Welcome; never the selection) | ⇧⌘S | Board window |
|
||||
| File | Save as Template | — (no default) | Board window; 09-templates.md |
|
||||
| File | Share… (the board, staged as a `.zip`, `NSSharingServicePicker` anchored to the board window's toolbar or its center) | — (no default) | Board window; design ruling 2026-08-09, card 72691b11 — a faithful copy like Duplicate's (`.git` the sole exclusion; attachments, comments and `.trash/` carried), never gated on the read-only lock (a share is a read, `Print…`'s own posture), disabled only while an inline title editor is focused |
|
||||
| File | Reveal in Finder | — (no default) | Board window: the selection's folder(s), or the board root with nothing selected; card window: the card's folder — the selected attachment's file instead when the attachments section is focused; welcome: the selected recent's folder (disabled on unavailable rows) — the context-menu entry's required twin |
|
||||
| File | Add Attachment… | ⇧⌘A | Card window |
|
||||
| File | Add Comment | — (no default) | Card window (all tiers — 12); if Show Comments is off, turns it on (persisted, the same user choice) and focuses the composer — 05 ▸ The comments column |
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
|
||||
/// File ▸ Share…'s staging half (design ruling 2026-08-09, card 72691b11 "create a share sheet
|
||||
/// target for board") — everything that happens **before** `NSSharingServicePicker` ever appears
|
||||
/// (`BoardSharePresentation`, the AppKit half this deliberately has none of).
|
||||
///
|
||||
/// ### A faithful copy, `.git` the sole exclusion
|
||||
///
|
||||
/// The ruling's own words: content rules follow **Save as Template's exclusion precedent**
|
||||
/// applied more narrowly — "exclude any inert `.git`; include everything else verbatim
|
||||
/// (attachments, comments, trash included — it's a faithful copy)". That is `BoardDuplicator`'s
|
||||
/// posture, not `TemplateEngine`'s: Save as Template drops **two** things (`.git` *and*
|
||||
/// `.trash/`) because a template is content, not a fork; Duplicate drops neither, because a
|
||||
/// duplicate *is* a fork. A share is neither of those — nobody is forking history (a recipient
|
||||
/// with no push access to gain nothing from a `.git` folder full of it, hence dropping it) or
|
||||
/// building a content-only template (the recipient is meant to see the board exactly as it
|
||||
/// stands, hence keeping the trash) — so it lands in between, one exclusion, everything else
|
||||
/// carried.
|
||||
///
|
||||
/// **The trash inclusion is flagged for owner review** (recorded in the card's DECISIONS
|
||||
/// comment): a shared board carries the sender's `.trash/` — cards they deleted — to the
|
||||
/// recipient, which is a real question ("does a shared board maybe not want to carry the
|
||||
/// recipient your deleted cards?") this file answers one way (include, matching Duplicate's own
|
||||
/// "it's a faithful copy" reasoning literally) without pretending the other answer is unreasonable.
|
||||
///
|
||||
/// ### Two steps, and a temp tree that never survives the zip
|
||||
///
|
||||
/// `stage(boardAt:titled:)` runs `stageTree(boardAt:into:)` (a plain `BoardTreeCopy` walk — the
|
||||
/// exact machinery `BoardDuplicator` and `TemplateEngine` already share, `.trash/`'s exclusion
|
||||
/// list narrowed to one entry) and then `DittoZipArchiver.zip(contentsOf:to:)` over the result,
|
||||
/// **removing the unzipped tree the moment the zip exists** — the ruling asks for one file, the
|
||||
/// `.zip`, at the destination the picker is handed, and there is no reason a decompressed second
|
||||
/// copy of the board (trash included) should keep occupying the temp volume for the whole life of
|
||||
/// the share sheet.
|
||||
///
|
||||
/// The two are split into their own functions rather than folded into one, because the split is
|
||||
/// what makes the exclusion rule testable without the ditto subprocess in the loop at all
|
||||
/// (`BoardShareStagerTests`'s tree-only suite) — `DittoZipArchiverTests` is where the subprocess
|
||||
/// itself gets its own, separate verification.
|
||||
///
|
||||
/// ### Atomicity: construct-then-clean, `TemplateEngine`'s own note
|
||||
///
|
||||
/// "A half-staged share must never be left for the picker to find." The whole staging directory is
|
||||
/// created by this call and removed by this call on every exit that is not a `StagedArchive` —
|
||||
/// cancellation and failure alike (09-templates.md's atomicity note, applied one boundary over).
|
||||
/// The one thing this file never owns is the **successful** exit's cleanup: a `StagedArchive` that
|
||||
/// lands is handed to `BoardSharePresentation`, which is the one thing that knows when the
|
||||
/// picker's interaction has actually finished — dismissed, failed, or a completed share — and
|
||||
/// therefore the one thing that calls `cleanup(_:)` on that path.
|
||||
///
|
||||
/// ### Not `@MainActor`
|
||||
///
|
||||
/// A board's `.trash/` and attachments can be real bytes, and `ditto` zipping them is real I/O —
|
||||
/// the same reason `BoardTreeCopy` and `BoardDuplicator` stay off the main actor so the banner's
|
||||
/// in-progress row can actually spin. Safe by construction, for the identical reason: this touches
|
||||
/// only the two URLs it is handed and a staging directory it alone created.
|
||||
enum BoardShareStager {
|
||||
|
||||
// MARK: - Outcomes
|
||||
|
||||
/// The two ways staging ends without a `StagedArchive` — `TemplateEngine.Failure`'s own
|
||||
/// vocabulary, for the same reason: the destination is a temp directory this app always owns,
|
||||
/// so there is no save-panel-shaped `.refused` question to ask, only "the user cancelled" or
|
||||
/// "something else went wrong".
|
||||
enum Failure: Error, Sendable, Equatable {
|
||||
/// The user cancelled (the banner row's Cancel). The staging directory is already gone —
|
||||
/// "a cancelled share never happened", `BoardDuplicator`'s rule verbatim.
|
||||
case cancelled
|
||||
|
||||
/// Anything else: a full disk, an unreadable board, `ditto` itself refusing. The ordinary
|
||||
/// one-shot banner's vocabulary.
|
||||
case failed(BoardWriteError)
|
||||
}
|
||||
|
||||
/// What a successful stage hands `BoardSharePresentation`: the whole staging directory (which
|
||||
/// `cleanup(_:)` removes wholesale) and the one `.zip` file inside it the picker actually
|
||||
/// shares.
|
||||
struct StagedArchive: Sendable, Equatable {
|
||||
let root: URL
|
||||
let zipURL: URL
|
||||
}
|
||||
|
||||
// MARK: - Naming
|
||||
|
||||
/// `"<Board Title>.zip"`'s left half — the board's display title, made safe as a path
|
||||
/// component.
|
||||
///
|
||||
/// Neither `BoardDuplicator` nor `TemplateEngine` sanitizes a name: both build their
|
||||
/// destination from a folder name already legal on disk (`copyDestination(for:)` reads the
|
||||
/// source `URL`'s own `lastPathComponent`), never from the frontmatter `title:` string this
|
||||
/// function takes. A share's filename is the first place in the app a **freeform title**
|
||||
/// becomes a **filename** on its own, so this is genuinely new, not a convention borrowed from
|
||||
/// either sibling — checked first, per the card's instructions, and found absent rather than
|
||||
/// found and mirrored.
|
||||
///
|
||||
/// `/` is the one byte APFS/HFS+ actually forbids in a path component; `:` is legal on disk
|
||||
/// but has read as a path separator in Finder since the Carbon HFS colon-mapping, and is the
|
||||
/// one other character this codebase already treats as filename-hostile
|
||||
/// (`BoardRegistry.quarantine`'s own colon replacement, on a timestamp rather than a title, but
|
||||
/// the same character for the same reason). Both become `-`, trimmed, and an all-whitespace or
|
||||
/// empty result falls back to `"Board"` — the same word `WriterFixture.board(title:)`'s own
|
||||
/// default uses, so an untitled board's zip reads as "a board" rather than as an empty string
|
||||
/// AppKit would have to reject the file at all.
|
||||
static func sanitizedFilenameComponent(from title: String) -> String {
|
||||
let replaced = title
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
.replacingOccurrences(of: ":", with: "-")
|
||||
let trimmed = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Board" : trimmed
|
||||
}
|
||||
|
||||
// MARK: - Staging
|
||||
|
||||
/// A fresh, empty staging directory nobody else could be using — `NSTemporaryDirectory()`
|
||||
/// rather than beside the board (unlike Duplicate's sibling): there is no Finder-visible
|
||||
/// destination here to place next to anything, and the app's own temp directory is always
|
||||
/// writable, which is exactly why sharing carries no `.refused` outcome at all.
|
||||
private static func makeStagingRoot() throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("dev.rzen.indie.Kanban-share-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
return root
|
||||
}
|
||||
|
||||
/// The copy half, alone and testable without a subprocess: `rootURL`'s tree, minus `.git`,
|
||||
/// landing at `destination` — `BoardTreeCopy`'s ordinary per-item walk, carrying folder
|
||||
/// attributes the way `BoardDuplicator` does ("a faithful copy", the ruling's own words,
|
||||
/// echoing Duplicate's rather than the template engine's stripped-attributes posture).
|
||||
static func stageTree(
|
||||
boardAt rootURL: URL,
|
||||
into destination: URL,
|
||||
isCancelled: () -> Bool
|
||||
) throws(BoardTreeCopy.Stop) {
|
||||
try BoardTreeCopy.copy(
|
||||
contentsOf: rootURL,
|
||||
into: destination,
|
||||
excludingTopLevel: [".git"],
|
||||
carryingFolderAttributes: true,
|
||||
isCancelled: isCancelled
|
||||
)
|
||||
}
|
||||
|
||||
/// Stages `rootURL` for sharing: a temp directory holding one `.zip`, named from `title`.
|
||||
///
|
||||
/// `title` is the board's resolved display name (`AppModel.displayName(of:)`, the same read
|
||||
/// `DuplicateBoardCommand` takes) — never optional here, unlike the `WriteOperation` payload it
|
||||
/// feeds: a share's filename needs *some* word, and `AppModel.displayName(of:)` already
|
||||
/// guarantees one (title, falling back to the folder name), so there is no genuinely titleless
|
||||
/// case for this function to represent.
|
||||
///
|
||||
/// `isCancelled` is read between the walk's items and nowhere else, `BoardDuplicator`'s own
|
||||
/// seam — the zip step itself, once started, runs to completion rather than mid-process
|
||||
/// (`DittoZipArchiver`'s own note: this app does not partially zip a file any more than
|
||||
/// `BoardTreeCopy` partially copies one).
|
||||
static func stage(
|
||||
boardAt rootURL: URL,
|
||||
titled title: String,
|
||||
isCancelled: () -> Bool = { Task.isCancelled }
|
||||
) throws(Failure) -> StagedArchive {
|
||||
let operation = WriteOperation.shareBoard(title: title)
|
||||
let name = sanitizedFilenameComponent(from: title)
|
||||
|
||||
func failure(at url: URL, _ message: String) -> Failure {
|
||||
.failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
|
||||
}
|
||||
|
||||
let root: URL
|
||||
do {
|
||||
root = try makeStagingRoot()
|
||||
} catch {
|
||||
throw failure(at: rootURL, "could not create a staging folder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Cancelled before it began is still cancelled — checked before the tree walk starts so an
|
||||
// empty staging directory never lingers for a share nobody asked to continue.
|
||||
if isCancelled() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw .cancelled
|
||||
}
|
||||
|
||||
let treeCopy = root.appendingPathComponent(name, isDirectory: true)
|
||||
let zipURL = root.appendingPathComponent("\(name).zip", isDirectory: false)
|
||||
|
||||
do {
|
||||
try BoardTreeCopy.createDirectory(at: treeCopy)
|
||||
try stageTree(boardAt: rootURL, into: treeCopy, isCancelled: isCancelled)
|
||||
} catch let stop as BoardTreeCopy.Stop {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
switch stop {
|
||||
case .cancelled:
|
||||
throw .cancelled
|
||||
case let .failed(url, underlying):
|
||||
throw failure(at: url, "could not copy the board: \(underlying.localizedDescription)")
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw failure(at: treeCopy, "could not create the staging folder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Between the copy and the zip, once more — a Cancel pressed the instant the walk finished
|
||||
// must not still spawn `ditto` over a tree nobody wants shared.
|
||||
if isCancelled() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw .cancelled
|
||||
}
|
||||
|
||||
do {
|
||||
try DittoZipArchiver.zip(contentsOf: treeCopy, to: zipURL)
|
||||
} catch let zipFailure as DittoZipArchiver.Failure {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
switch zipFailure {
|
||||
case let .launchFailed(message):
|
||||
throw failure(at: treeCopy, "could not start the archiver: \(message)")
|
||||
case let .nonZeroExit(status, message):
|
||||
throw failure(at: treeCopy, "the archiver exited with status \(status): \(message)")
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw failure(at: treeCopy, "could not create the zip: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// The zip is what the picker shares; the decompressed tree underneath it was only ever a
|
||||
// means to that one file, and leaving it beside the zip would be a second, needless copy of
|
||||
// the board's attachments and trash sitting in temp storage for the picker's whole lifetime.
|
||||
try? FileManager.default.removeItem(at: treeCopy)
|
||||
|
||||
return StagedArchive(root: root, zipURL: zipURL)
|
||||
}
|
||||
|
||||
/// Removes a staged archive's whole directory — `BoardSharePresentation`'s call, once the
|
||||
/// picker's interaction has genuinely finished (dismissed without choosing, a service failed,
|
||||
/// or a service reported success). Best-effort: a cleanup that cannot remove a temp file it no
|
||||
/// longer needs is not a reason to trouble the user with a banner about it.
|
||||
static func cleanup(_ archive: StagedArchive) {
|
||||
try? FileManager.default.removeItem(at: archive.root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// **The one place this app creates a `.zip`** — `/usr/bin/ditto -c -k`, spawned as a child
|
||||
/// process, for File ▸ Share… (`BoardShareStager`; design ruling 2026-08-09, card 72691b11).
|
||||
///
|
||||
/// ### Why a spawned system tool rather than a library or a framework
|
||||
///
|
||||
/// Foundation has no public zip-writing API, and the two frameworks that could stand in for one
|
||||
/// both miss: `AppleArchive` (macOS 11+) writes `.aar`/LZFSE streams, not the `.zip` format every
|
||||
/// share destination this card targets (Mail, Messages, AirDrop, Finder) actually expects to open;
|
||||
/// pulling in a third-party archiver (libarchive, minizip, …) is real dependency weight for one
|
||||
/// button. `ditto -c -k` is Apple's own documented recipe for "make a zip archive" outside of
|
||||
/// Finder's Compress menu, and it is what Finder's own Compress item shells out to — so a `.zip`
|
||||
/// this produces is the same bytes a user's own Finder compress would have written.
|
||||
///
|
||||
/// ### The sandbox question, answered rather than assumed
|
||||
///
|
||||
/// A sandboxed app's `Process` spawns a **child that inherits the parent's sandbox** rather than
|
||||
/// running unconfined — the concern the card's design ruling asks to "VERIFY in a sandboxed
|
||||
/// build" rather than take on faith. `/usr/bin/ditto` is a platform binary reading and writing
|
||||
/// only paths this app's own process already owns (`BoardShareStager`'s staging directory, inside
|
||||
/// the app's temporary directory — no security-scoped bookmark, no path outside the sandbox the
|
||||
/// process wasn't already free to touch), so there is no privilege the child needs that the parent
|
||||
/// does not already have. **Verified empirically, not just argued**: `DittoZipArchiverTests` runs
|
||||
/// this exact call inside `KanbanTests`, which is host-application-hosted
|
||||
/// (`TEST_HOST = .../Lanework.app/...` in `project.yml`) and therefore runs *as* the sandboxed app
|
||||
/// process, entitlements included — the closest this suite can get to the real Share button
|
||||
/// without driving AppKit. A green run there is the sandboxed build the ruling asked for.
|
||||
///
|
||||
/// If a future OS or entitlement change ever makes that test go red, the ruling's own fallback is
|
||||
/// recorded at the call site that would need it (`BoardShareStager`'s doc comment): share the
|
||||
/// board's **folder URL** directly rather than a zip, which needs no subprocess at all.
|
||||
enum DittoZipArchiver {
|
||||
|
||||
/// Why the archive did not get made — `BoardShareStager` folds both into its own `.failed`.
|
||||
enum Failure: Error, Sendable, Equatable {
|
||||
/// `Process.run()` itself threw — the executable could not be launched at all (missing,
|
||||
/// unreadable, or a sandbox denial of the spawn itself rather than of what runs inside it).
|
||||
case launchFailed(String)
|
||||
|
||||
/// `ditto` ran and exited non-zero — its own stderr, which names the actual reason (a
|
||||
/// source that vanished mid-copy, a destination it could not create) far better than this
|
||||
/// type could reword it.
|
||||
case nonZeroExit(status: Int32, message: String)
|
||||
}
|
||||
|
||||
/// Zips the **contents** of `source` into `destination` — `--keepParent` so the archive's one
|
||||
/// top-level entry is `source`'s own folder name (a recipient decompressing the `.zip` gets
|
||||
/// back a folder named after the board, not its contents spilled loose), and
|
||||
/// `--sequesterRsrc` so any resource forks or Finder metadata land in `__MACOSX/` alongside
|
||||
/// the payload rather than corrupting it for a non-Apple unzip on the receiving end — the same
|
||||
/// two flags Finder's own Compress uses.
|
||||
///
|
||||
/// `destination`'s parent must already exist; `ditto` does not create intermediate
|
||||
/// directories, and this call does not either — `BoardShareStager` owns the staging
|
||||
/// directory's lifecycle, this only ever writes one file into a folder that is already there.
|
||||
///
|
||||
/// Synchronous and blocking — callers running this off the main actor (`BoardShareStager`,
|
||||
/// via `Task.detached`) is what keeps a multi-hundred-megabyte board's zip from freezing a
|
||||
/// spinner, the same posture `BoardTreeCopy`'s own doc comment states for the plain-copy half
|
||||
/// of this same flow.
|
||||
static func zip(contentsOf source: URL, to destination: URL) throws(Failure) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||
process.arguments = ["-c", "-k", "--sequesterRsrc", "--keepParent", source.path, destination.path]
|
||||
|
||||
// Piped rather than inherited: a child's stdout would otherwise interleave with this
|
||||
// process's own on the same terminal/log, and stderr is read back on a non-zero exit,
|
||||
// which is the only time `ditto`'s own words are worth surfacing.
|
||||
process.standardOutput = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw .launchFailed(error.localizedDescription)
|
||||
}
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw .nonZeroExit(status: process.terminationStatus, message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -186,11 +186,15 @@ struct KanbanApp: App {
|
||||
IndieAboutCommand(configuration: AboutBox.configuration)
|
||||
|
||||
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
||||
// Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Reveal in Finder, Add
|
||||
// Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate against
|
||||
// the frontmost board through the focus system, so each is simply absent-of-effect when no
|
||||
// board is in front; New Board… and Open Recent are available everywhere, including with no
|
||||
// window at all. Close is the system's own item — no row of ours to add.
|
||||
// Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Share…, Reveal in Finder,
|
||||
// Add Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate
|
||||
// against the frontmost board through the focus system, so each is simply absent-of-effect
|
||||
// when no board is in front; New Board… and Open Recent are available everywhere, including
|
||||
// with no window at all. Close is the system's own item — no row of ours to add.
|
||||
//
|
||||
// **Share… joined 2026-08-09** (design ruling, card 72691b11) right after Save as
|
||||
// Template: the third command in a row that copies the whole board wholesale, each to a
|
||||
// different destination — a sibling folder, the templates store, or a share sheet.
|
||||
CommandGroup(after: .newItem) {
|
||||
BoardCreationCommands()
|
||||
NewBoardCommand(appModel: appModel)
|
||||
@@ -212,6 +216,7 @@ struct KanbanApp: App {
|
||||
|
||||
DuplicateBoardCommand(appModel: appModel)
|
||||
SaveAsTemplateCommand(appModel: appModel)
|
||||
ShareBoardCommand(appModel: appModel)
|
||||
RevealInFinderCommand()
|
||||
AddAttachmentCommand()
|
||||
AddCommentCommand()
|
||||
|
||||
@@ -917,6 +917,12 @@ public final class BannerCenter {
|
||||
// pressed and the board is still exactly as it was: nothing was saved *over*, and the
|
||||
// failure is about the copy in the templates folder, not about this board's own files.
|
||||
if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" }
|
||||
case let .shareBoard(title):
|
||||
// The command's own word (File ▸ Share…), on `.saveAsTemplate`'s reasoning: the board
|
||||
// itself is untouched by a share that fails partway — nothing here was saved *over* —
|
||||
// so the sentence is about the staged copy that never reached the picker, not about
|
||||
// this board's own files.
|
||||
if let title { "Couldn't share '\(title)'" } else { "Couldn't share the board" }
|
||||
case let .importAttachment(filename):
|
||||
"Couldn't import '\(filename)'"
|
||||
case .listAttachments:
|
||||
|
||||
@@ -3010,6 +3010,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// `title` is the board's display name.
|
||||
case saveAsTemplate(title: String?)
|
||||
|
||||
/// File ▸ Share… — the whole board staged into a `.zip` for `NSSharingServicePicker`
|
||||
/// (design ruling 2026-08-09, card 72691b11; `BoardShareStager`). Its own case beside
|
||||
/// `.duplicateBoard` and `.saveAsTemplate`, on both their reasoning: a third command copies a
|
||||
/// board wholesale for a third reason, and a banner saying the app could not "duplicate" or
|
||||
/// "save as template" a board nobody asked to duplicate or template would name gestures that
|
||||
/// never happened. `title` is the board's display name.
|
||||
case shareBoard(title: String?)
|
||||
|
||||
case importAttachment(filename: String)
|
||||
case listAttachments
|
||||
|
||||
@@ -3246,6 +3254,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .rename: .rename(title: title)
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .saveAsTemplate: .saveAsTemplate(title: title)
|
||||
case .shareBoard: .shareBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
case .rawSource: .rawSource(title: title)
|
||||
@@ -3289,7 +3298,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .paste,
|
||||
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
|
||||
.importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
@@ -3326,6 +3335,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .rename(title): Self.phrase("rename", title)
|
||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||
case let .saveAsTemplate(title): Self.phrase("save as template", title)
|
||||
case let .shareBoard(title): Self.phrase("share board", title)
|
||||
case let .importAttachment(filename): "import attachment '\(filename)'"
|
||||
case .listAttachments: "list attachments"
|
||||
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// File ▸ Share…'s staging half (`BoardShareStager`; design ruling 2026-08-09, card 72691b11).
|
||||
///
|
||||
/// Three promises, and all three are about what lands in the staged `.zip`:
|
||||
///
|
||||
/// - **`.git` is the sole exclusion** — attachments, comments and `.trash/` all carry through
|
||||
/// verbatim, "a faithful copy" in the ruling's own words;
|
||||
/// - **the filename is the sanitized title**, `"<Board Title>.zip"`, never the on-disk folder name
|
||||
/// (the one place this app turns a freeform `title:` string into a path component);
|
||||
/// - **cancellation and failure leave no residue** — the staging directory this call created is
|
||||
/// the one it also removes, `BoardDuplicator`'s and `TemplateEngine`'s own rule at a third
|
||||
/// boundary.
|
||||
///
|
||||
/// The picker (`BoardSharePresentation`) and the subprocess itself (`DittoZipArchiver`) are
|
||||
/// exercised elsewhere — this suite is `stageTree(boardAt:into:isCancelled:)`, the plain
|
||||
/// `FileManager` half, so the exclusion rule is provable without a subprocess anywhere in the
|
||||
/// loop, plus a handful of end-to-end `stage(boardAt:titled:)` runs that do call through to
|
||||
/// `ditto` (`DittoZipArchiverTests` is where that subprocess call gets its own, separate
|
||||
/// verification that it actually works inside this app's sandbox).
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// A board carrying everything the exclusion rule has an opinion about: `.git`, `.trash/`, a
|
||||
/// comment thread with its own `.trash/`, an attachment, and a board-level stray.
|
||||
private struct ShareableBoard {
|
||||
|
||||
let fixture: WriterFixture
|
||||
let root: URL
|
||||
|
||||
static let name = "Roadmap.kanban"
|
||||
|
||||
init() throws {
|
||||
fixture = try WriterFixture()
|
||||
root = fixture.url(Self.name)
|
||||
let path = Self.name
|
||||
|
||||
try fixture.item(path, "---\nschema: 1\ntitle: Roadmap\n---\nThe board's description.\n")
|
||||
try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
||||
|
||||
// The sole exclusion.
|
||||
try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
try fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02]))
|
||||
|
||||
// Carried: the board's own trash.
|
||||
try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)",
|
||||
Item.rich(order: "1024", title: "Thrown Away"))
|
||||
|
||||
try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do"))
|
||||
try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter"))
|
||||
try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03]))
|
||||
|
||||
// Carried: a comment thread. Excluded regardless (unconditionally, by `BoardTreeCopy`
|
||||
// itself): the comment thread's own `.trash/`.
|
||||
try fixture.item(
|
||||
"\(path)/\(Ident.lane1)/\(Ident.card1)/comments/\(Ident.card2)",
|
||||
"---\nschema: 1\nkind: comment\nauthor: tester\n---\nA comment.\n"
|
||||
)
|
||||
try fixture.item(
|
||||
"\(path)/\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Ident.card3)",
|
||||
"---\nschema: 1\nkind: comment\nauthor: tester\n---\nA deleted comment.\n"
|
||||
)
|
||||
}
|
||||
|
||||
func tearDown() { fixture.tearDown() }
|
||||
}
|
||||
|
||||
/// Every path under `root`, root-relative, hidden entries included.
|
||||
private func tree(of root: URL) -> Set<String> {
|
||||
var paths: Set<String> = []
|
||||
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
|
||||
while let url = walker?.nextObject() as? URL {
|
||||
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private func shareFailure(_ operation: () throws -> Void) -> BoardShareStager.Failure? {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("expected the share to fail, but it succeeded")
|
||||
return nil
|
||||
} catch let failure as BoardShareStager.Failure {
|
||||
return failure
|
||||
} catch {
|
||||
Issue.record("expected a BoardShareStager.Failure, got \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Naming
|
||||
|
||||
@Suite("BoardShareStager — the filename")
|
||||
struct BoardShareStagerNamingTests {
|
||||
|
||||
@Test("An ordinary title needs no changes")
|
||||
func ordinaryTitlePassesThrough() {
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap") == "Roadmap")
|
||||
}
|
||||
|
||||
@Test("A slash is the one byte the filesystem actually forbids, and it is replaced")
|
||||
func slashIsReplaced() {
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Q1/Q2 Plan") == "Q1-Q2 Plan")
|
||||
}
|
||||
|
||||
@Test("A colon is legal on disk but reads as a path separator in Finder, so it goes too")
|
||||
func colonIsReplaced() {
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap: 2026") == "Roadmap- 2026")
|
||||
}
|
||||
|
||||
@Test("Surrounding whitespace is trimmed")
|
||||
func whitespaceIsTrimmed() {
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: " Roadmap ") == "Roadmap")
|
||||
}
|
||||
|
||||
@Test("An empty or all-whitespace title falls back to 'Board'")
|
||||
func emptyTitleFallsBack() {
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: "") == "Board")
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: " ") == "Board")
|
||||
// Made entirely of the one replaced character, and nothing left once it's swapped for '-'.
|
||||
#expect(BoardShareStager.sanitizedFilenameComponent(from: "/") == "-")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tree copy's exclusion rule
|
||||
|
||||
@Suite("BoardShareStager — the tree copy")
|
||||
struct BoardShareStagerTreeTests {
|
||||
|
||||
@Test("`.git` is the sole exclusion — everything else, trash and comments included, rides along")
|
||||
func onlyGitIsExcluded() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let destination = board.fixture.url("staged")
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
|
||||
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
|
||||
|
||||
let paths = tree(of: destination)
|
||||
#expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
|
||||
"inert history nobody receiving a shared board asked for")
|
||||
#expect(paths.contains { $0.hasPrefix("\(BoardLoader.trashFolderName)/") },
|
||||
"a share is a faithful copy — Duplicate's posture, not Save as Template's")
|
||||
#expect(paths.contains { $0.hasSuffix("attachments/shot.png") })
|
||||
#expect(paths.contains { $0.hasSuffix("comments/\(Ident.card2)/index.md") })
|
||||
#expect(paths.contains("CLAUDE.user.md"))
|
||||
#expect(!paths.contains { $0.contains("comments/.trash") },
|
||||
"the comment thread's own trash never travels — BoardTreeCopy's unconditional rule")
|
||||
}
|
||||
|
||||
@Test("The copy is byte for byte")
|
||||
func contentIsVerbatim() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let destination = board.fixture.url("staged")
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
|
||||
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
|
||||
|
||||
#expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md"))
|
||||
== Data("board instructions\n".utf8))
|
||||
#expect(try Data(contentsOf: destination.appendingPathComponent(
|
||||
"\(Ident.lane1)/\(Ident.card1)/attachments/shot.png"
|
||||
)) == Data([0x01, 0x02, 0x03]))
|
||||
}
|
||||
|
||||
@Test("The board being shared is never written to")
|
||||
func originalIsUntouched() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let before = tree(of: board.root)
|
||||
let destination = board.fixture.url("staged")
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
|
||||
try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })
|
||||
|
||||
#expect(tree(of: board.root) == before)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - End to end
|
||||
|
||||
@Suite("BoardShareStager — stage(boardAt:titled:)")
|
||||
struct BoardShareStagerEndToEndTests {
|
||||
|
||||
@Test("The zip lands named from the title, not the folder")
|
||||
func zipIsNamedFromTheTitle() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Q1 Roadmap")
|
||||
defer { BoardShareStager.cleanup(archive) }
|
||||
|
||||
#expect(archive.zipURL.lastPathComponent == "Q1 Roadmap.zip",
|
||||
"the board's own display title — the folder is 'Roadmap.kanban', the title is not")
|
||||
#expect(FileManager.default.fileExists(atPath: archive.zipURL.path))
|
||||
}
|
||||
|
||||
@Test("The decompressed tree does not survive the zip — one file is left for the picker")
|
||||
func onlyTheZipRemains() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
|
||||
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
|
||||
defer { BoardShareStager.cleanup(archive) }
|
||||
|
||||
let entries = try FileManager.default.contentsOfDirectory(atPath: archive.root.path)
|
||||
#expect(
|
||||
entries == ["Roadmap.zip"],
|
||||
"the intermediate decompressed copy — trash and attachments included — must not sit in temp storage for the picker's whole lifetime"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("cleanup(_:) removes the whole staging directory")
|
||||
func cleanupRemovesEverything() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(FileManager.default.fileExists(atPath: archive.root.path))
|
||||
|
||||
BoardShareStager.cleanup(archive)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: archive.root.path))
|
||||
}
|
||||
|
||||
@Test("A share cancelled before it began leaves no staging directory at all")
|
||||
func cancellingBeforeTheFirstItemCreatesNothing() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
|
||||
|
||||
let failure = shareFailure {
|
||||
_ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
|
||||
}
|
||||
|
||||
#expect(failure == .cancelled)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
|
||||
"no 'dev.rzen.indie.Kanban-share-…' folder survives — a cancelled share never happened")
|
||||
}
|
||||
|
||||
@Test("Cancelling mid-walk removes the partial staging directory")
|
||||
func cancellingMidWalkRemovesThePartial() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
|
||||
|
||||
var reads = 0
|
||||
let failure = shareFailure {
|
||||
_ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: {
|
||||
reads += 1
|
||||
return reads > 3
|
||||
})
|
||||
}
|
||||
|
||||
#expect(failure == .cancelled)
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before)
|
||||
}
|
||||
|
||||
@Test("A board that isn't there fails as a share, naming the operation, and leaves nothing behind")
|
||||
func missingSourceFailsInTheShareVocabulary() throws {
|
||||
let board = try ShareableBoard()
|
||||
defer { board.tearDown() }
|
||||
let missing = board.fixture.url("Never.kanban")
|
||||
let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)
|
||||
|
||||
let failure = shareFailure {
|
||||
_ = try BoardShareStager.stage(boardAt: missing, titled: "Never")
|
||||
}
|
||||
|
||||
guard case let .failed(error) = failure else {
|
||||
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
||||
return
|
||||
}
|
||||
#expect(error.operation == .shareBoard(title: "Never"),
|
||||
"the user pressed Share; the banner must say so")
|
||||
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't share 'Never'"))
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
|
||||
"a half-staged share is residue — it goes, and the banner is what remains")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu validation
|
||||
|
||||
@Suite("ShareBoardCommand — validation")
|
||||
struct ShareBoardCommandValidationTests {
|
||||
|
||||
@Test("Needs both a focused board window and its session ref")
|
||||
func needsStoreAndRef() {
|
||||
#expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: false, isEditingInline: false))
|
||||
#expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: false, isEditingInline: false))
|
||||
#expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: true, isEditingInline: false))
|
||||
#expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
|
||||
}
|
||||
|
||||
@Test("An open inline title editor holds a pending change no flush can reach")
|
||||
func inlineEditingDisablesIt() {
|
||||
#expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: true))
|
||||
}
|
||||
|
||||
@Test("Unlike Duplicate and Save as Template, the read-only lock never gates it — a share is a read")
|
||||
func theLockIsNeverConsulted() {
|
||||
// `isEnabled` takes no lock parameter at all — this test pins that omission as
|
||||
// deliberate: adding one back would be the change to catch here, not a passing assertion.
|
||||
#expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The sandbox verification the card's design ruling asks for**: "prefer … `Process`+`/usr/bin/
|
||||
/// ditto` … VERIFY in a sandboxed build".
|
||||
///
|
||||
/// `KanbanTests` is host-application-hosted (`TEST_HOST` in `project.yml` points at the built
|
||||
/// `Lanework.app`), so this suite runs *inside* the real, sandboxed app process —
|
||||
/// `Kanban.entitlements`'s `com.apple.security.app-sandbox` and all — rather than in a bare XCTest
|
||||
/// bundle with none of the app's confinement. A green run here is empirical: `/usr/bin/ditto`,
|
||||
/// spawned as this app's child, can read a folder this process owns and write a `.zip` beside it,
|
||||
/// under the same entitlements a real File ▸ Share… run would have. That is the closest this suite
|
||||
/// can get to the real button without driving AppKit (`BoardSharePresentation`'s own boundary).
|
||||
@Suite("DittoZipArchiver")
|
||||
struct DittoZipArchiverTests {
|
||||
|
||||
@Test("A folder zips inside the sandbox, and the result is a real zip")
|
||||
func zipsInsideTheSandbox() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let source = fixture.url("Payload")
|
||||
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
|
||||
try Data("hello from the sandbox\n".utf8).write(to: source.appendingPathComponent("note.txt"))
|
||||
try FileManager.default.createDirectory(
|
||||
at: source.appendingPathComponent("nested", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data([0x01, 0x02, 0x03]).write(to: source.appendingPathComponent("nested/data.bin"))
|
||||
|
||||
let destination = fixture.url("Payload.zip")
|
||||
try DittoZipArchiver.zip(contentsOf: source, to: destination)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: destination.path))
|
||||
let bytes = try Data(contentsOf: destination)
|
||||
#expect(!bytes.isEmpty)
|
||||
// The local file header signature `PK\x03\x04` — proof this is an actual zip archive and
|
||||
// not, say, an empty file `ditto` merely touched.
|
||||
#expect(bytes.prefix(4) == Data([0x50, 0x4B, 0x03, 0x04]))
|
||||
}
|
||||
|
||||
@Test("A source that doesn't exist is a failure, not a silent empty archive")
|
||||
func missingSourceFails() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let missing = fixture.url("Never")
|
||||
let destination = fixture.url("Never.zip")
|
||||
|
||||
#expect(throws: DittoZipArchiver.Failure.self) {
|
||||
try DittoZipArchiver.zip(contentsOf: missing, to: destination)
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: destination.path))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user