Build the welcome screen
The welcome window becomes the real thing: Xcode-style, hidden title bar with background drag, branding and actions left, recents right — rows carrying the board symbol, name, location, and the registry's cached lane/card counts (stamped at close, never a scan at welcome time), sorted by last opened. Launch failures surface row-level per 02: a failure joins its recents row as a warning caption, an unresolvable bookmark renders unavailable with Forget its one affordance, and only a failure with no row to carry it falls back to a compact list; a board opening again heals its row. New Board (Opt-Cmd-N) opens the Pages-style template chooser — shipped with the single Basic template and the m9 seams marked — flowing through the save panel into createBoard/createLane and straight into a board window. Open Recent gains its submenu with Clear Menu (byte-identical to forgetting every row, pinned by test), and File > Duplicate forks the frontmost board to a Finder-style copy sibling: pending work flushes first through the close flush's step two alone (sessions stay open — 09's stated exception), every GUID and tombstone carries (the whole-board carve-out from copies-remint), and the copy opens in its own window while the original stays put. 36 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates).
|
||||
///
|
||||
/// Three promises, and all three are about what the copy *doesn't* do:
|
||||
///
|
||||
/// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out,
|
||||
/// and the thing that keeps a copied `.git` history naming paths that still exist;
|
||||
/// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is
|
||||
/// born exactly matching its history";
|
||||
/// - it doesn't overwrite — the Finder-style `copy` ladder renames instead.
|
||||
///
|
||||
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
|
||||
/// window flow around the copy (the flush, the banner row, the open that follows) is the command's,
|
||||
/// not this type's.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file —
|
||||
/// everything a literal copy has to carry through untouched.
|
||||
@MainActor
|
||||
private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) {
|
||||
let fixture = try WriterFixture()
|
||||
let root = fixture.url(name)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
try fixture.item(name, Item.board)
|
||||
try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item(
|
||||
"\(name)/\(Ident.lane1)/\(Ident.card2)",
|
||||
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item(
|
||||
"\(name)/\(Ident.lane2)",
|
||||
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried"))
|
||||
try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
||||
|
||||
return (fixture, root)
|
||||
}
|
||||
|
||||
/// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy"
|
||||
/// means as an assertion.
|
||||
private func tree(of root: URL) throws -> 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
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardDuplicator")
|
||||
struct BoardDuplicatorTests {
|
||||
|
||||
// MARK: The name ladder
|
||||
|
||||
@Test("The first duplicate is a 'copy' sibling, extension carried")
|
||||
func firstCopyIsNamedFinderStyle() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let destination = BoardDuplicator.copyDestination(for: board.root)
|
||||
|
||||
#expect(destination.lastPathComponent == "Roadmap copy.kanban")
|
||||
#expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path,
|
||||
"a sibling — where the user is already looking")
|
||||
}
|
||||
|
||||
@Test("An extension-less board has none to carry")
|
||||
func extensionlessBoardCopiesWithoutOne() throws {
|
||||
let board = try makeBoard(named: "Plain")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy")
|
||||
}
|
||||
|
||||
@Test("The collision ladder counts up from 2, one collision at a time")
|
||||
func collisionLadderCountsUp() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(first.lastPathComponent == "Roadmap copy.kanban")
|
||||
|
||||
let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(second.lastPathComponent == "Roadmap copy 2.kanban")
|
||||
|
||||
let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
#expect(third.lastPathComponent == "Roadmap copy 3.kanban")
|
||||
|
||||
// And duplicating a duplicate ladders off *its* name, exactly as Finder does.
|
||||
let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy")
|
||||
#expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban")
|
||||
}
|
||||
|
||||
@Test("Anything already wearing the name blocks it, folder or file alike")
|
||||
func anyExistingEntryBlocksTheName() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
// Not a board — just a file in the way. A duplicate must rename around it, never through it.
|
||||
try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8))
|
||||
|
||||
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban")
|
||||
}
|
||||
|
||||
// MARK: What lands in the copy
|
||||
|
||||
@Test("Every GUID is kept — the whole-board carve-out from the remint rule")
|
||||
func guidsArePreserved() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
#expect(try tree(of: copy) == tree(of: board.root),
|
||||
"same identities, same nesting — a copied history keeps naming paths that exist")
|
||||
#expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Tombstoned lanes and cards are carried, trash included")
|
||||
func tombstonesAreCarried() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
let loaded = try BoardLoader.load(boardRoot: copy).model
|
||||
|
||||
let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 })
|
||||
#expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork")
|
||||
#expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it")
|
||||
|
||||
let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 })
|
||||
#expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted })
|
||||
}
|
||||
|
||||
@Test("The copy is byte-for-byte, strays and stale attribution included")
|
||||
func contentIsCopiedVerbatim() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
|
||||
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
func bytes(_ root: URL, _ relative: String) throws -> Data {
|
||||
try Data(contentsOf: root.appendingPathComponent(relative))
|
||||
}
|
||||
#expect(try bytes(copy, "index.md") == bytes(board.root, "index.md"))
|
||||
#expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md")
|
||||
== bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"),
|
||||
"created, unknown keys and modified-by all survive — nothing here reads a board file")
|
||||
#expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md"))
|
||||
}
|
||||
|
||||
@Test("The original is untouched by its own duplication")
|
||||
func originalSurvivesUnchanged() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
let before = try tree(of: board.root)
|
||||
|
||||
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
||||
|
||||
#expect(try tree(of: board.root) == before)
|
||||
}
|
||||
|
||||
// MARK: Failure
|
||||
|
||||
@Test("A source that isn't there fails as a duplicate, naming the board")
|
||||
func missingSourceFailsInTheDuplicateVocabulary() throws {
|
||||
let board = try makeBoard(named: "Roadmap.kanban")
|
||||
defer { board.fixture.tearDown() }
|
||||
let missing = board.fixture.url("Never.kanban")
|
||||
|
||||
let error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
|
||||
|
||||
#expect(error?.operation == .duplicateBoard(title: "Never"),
|
||||
"the user pressed Duplicate; the banner must say so")
|
||||
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user