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
+308
View File
@@ -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))
}
}
+55
View File
@@ -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))
}
}