Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Comment attachments author in-window** (05-card-window.md ▸ The comments column, ruled
|
||||
/// 2026-07-29): a file dropped in the composer lands in the draft's `attachments/`, one dropped in an
|
||||
/// inline edit session lands in that comment's, and an authoring chip's Remove moves the file to the
|
||||
/// *system* Trash.
|
||||
///
|
||||
/// This suite is the **write** half — the two primitives and the shape guard that decides which
|
||||
/// folders are authoring surfaces at all — plus the draft *read* the composer restores from. The
|
||||
/// hover rule that chooses between them is pure and lives in `CardCommentsLayoutTests`; the pane's
|
||||
/// wiring is `CardCommentsTests`'.
|
||||
///
|
||||
/// Assertions are against the bytes on disk, `CommentWriteTests`' rule.
|
||||
|
||||
private let cardTitle = "Fix login"
|
||||
|
||||
// MARK: - Reading the draft
|
||||
|
||||
@Suite("Comments ▸ reading the draft")
|
||||
struct CommentDraftReadTests {
|
||||
|
||||
@Test("The composer's restore is the read: body and chips, straight off the folder")
|
||||
func loadDraftReadsBodyAndChips() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText(body: "half a thought\n"))
|
||||
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
|
||||
|
||||
let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
|
||||
#expect(draft.body == "half a thought\n")
|
||||
#expect(draft.attachments == ["shot.png"])
|
||||
#expect(!draft.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A card with no draft has nothing to restore")
|
||||
func noDraftIsNil() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
|
||||
#expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
|
||||
}
|
||||
|
||||
@Test("A draft folder with no readable index.md still reports its files")
|
||||
func anIndexlessDraftStillHasChips() throws {
|
||||
// The two-step-create shape, and the shape a drop-before-a-keystroke leaves. Reporting `nil`
|
||||
// would hide files the user can see in Finder.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
|
||||
|
||||
let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
|
||||
#expect(draft.body == "")
|
||||
#expect(draft.attachments == ["shot.png"])
|
||||
#expect(!draft.isEmpty, "a draft with no text but a file in it is still a draft")
|
||||
}
|
||||
|
||||
@Test("Emptiness is the Writer's own gate, asked forwards")
|
||||
func emptinessMirrorsTheDeleteRule() {
|
||||
#expect(CommentDraft(body: "", attachments: []).isEmpty)
|
||||
#expect(CommentDraft(body: " \n ", attachments: []).isEmpty)
|
||||
#expect(!CommentDraft(body: "", attachments: ["shot.png"]).isEmpty)
|
||||
#expect(!CommentDraft(body: "x", attachments: []).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A .draft held by a file is not a draft")
|
||||
func aSquattedDraftNameIsNotADraft() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.file("\(card)/comments/.draft", Data("not a folder".utf8))
|
||||
|
||||
#expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Importing
|
||||
|
||||
@Suite("Comments ▸ authoring attachments")
|
||||
struct CommentAttachmentImportTests {
|
||||
|
||||
private func source(_ fixture: WriterFixture, named name: String, _ text: String = "x") throws -> URL {
|
||||
try fixture.file("sources/\(name)", Data(text.utf8))
|
||||
}
|
||||
|
||||
@Test("A drop in the composer lands in the draft's attachments/")
|
||||
func importsIntoTheDraft() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
let file = try source(fixture, named: "shot.png")
|
||||
|
||||
let landed = try BoardWriter.importCommentAttachments(
|
||||
[file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
|
||||
)
|
||||
#expect(landed.map(\.fileName) == ["shot.png"])
|
||||
#expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
|
||||
}
|
||||
|
||||
@Test("A drop in an inline edit session lands in that comment's attachments/")
|
||||
func importsIntoAComment() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let file = try source(fixture, named: "shot.png")
|
||||
|
||||
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
|
||||
#expect(fixture.exists("\(path)/attachments/shot.png"))
|
||||
}
|
||||
|
||||
@Test("A name already taken is renamed Finder-style, never overwritten")
|
||||
func collisionsRenameFinderStyle() throws {
|
||||
// The card level's rule, shared rather than restated: `importFiles` is one implementation, so
|
||||
// a file added to a comment behaves exactly like one added to a card.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("first".utf8))
|
||||
let file = try source(fixture, named: "shot.png", "second")
|
||||
|
||||
let landed = try BoardWriter.importCommentAttachments(
|
||||
[file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
|
||||
)
|
||||
#expect(landed.map(\.fileName) == ["shot 2.png"])
|
||||
#expect(try fixture.data("\(card)/comments/.draft/attachments/shot.png") == Data("first".utf8))
|
||||
}
|
||||
|
||||
@Test("Importing opens no index.md — a file says nothing about the text beside it")
|
||||
func importStampsNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let before = try fixture.indexText(path)
|
||||
let file = try source(fixture, named: "shot.png")
|
||||
|
||||
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
|
||||
#expect(try fixture.indexText(path) == before, "the relocation rule: no stamp for a file move")
|
||||
}
|
||||
|
||||
@Test("A folder is refused, as it is at card level")
|
||||
func foldersAreRefused() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
let folder = fixture.url("sources/afolder")
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
let failure = writeFailure {
|
||||
_ = try BoardWriter.importCommentAttachments(
|
||||
[folder], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
|
||||
)
|
||||
}
|
||||
#expect(failure?.operation == .importAttachment(filename: "afolder"))
|
||||
}
|
||||
|
||||
@Test("comments/.trash/ is not an authoring surface")
|
||||
func theTrashRefusesAnImport() throws {
|
||||
// A deleted comment is undo's backing store and "never a UI surface" (01-storage-format.md §
|
||||
// Enhanced schema), so there is no gesture that could aim at one — and the Writer says so
|
||||
// rather than trusting that.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
||||
let file = try source(fixture, named: "shot.png")
|
||||
|
||||
let failure = writeFailure {
|
||||
_ = try BoardWriter.importCommentAttachments(
|
||||
[file],
|
||||
intoComment: CommentThread.trashedCommentFolder(
|
||||
ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card)
|
||||
)
|
||||
)
|
||||
}
|
||||
#expect(failure != nil)
|
||||
}
|
||||
|
||||
@Test("A card folder is not a comment folder either")
|
||||
func theCardIsRefused() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let file = try source(fixture, named: "shot.png")
|
||||
|
||||
let failure = writeFailure {
|
||||
_ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(card))
|
||||
}
|
||||
#expect(failure != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Removing
|
||||
|
||||
@Suite("Comments ▸ removing an authoring chip")
|
||||
struct CommentAttachmentRemoveTests {
|
||||
|
||||
@Test("Remove moves the file to the system Trash — never a hard delete")
|
||||
func removeTrashesTheFile() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))
|
||||
|
||||
let trashed = try BoardWriter.removeCommentAttachment(
|
||||
named: "shot.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
|
||||
)
|
||||
let landing = try #require(trashed)
|
||||
#expect(FileManager.default.fileExists(atPath: landing.path), "the file still exists, in the Trash")
|
||||
#expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
|
||||
try? FileManager.default.removeItem(at: landing)
|
||||
}
|
||||
|
||||
@Test("A name that is no longer there is not a failure")
|
||||
func aVanishedNameIsANoOp() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
|
||||
#expect(try BoardWriter.removeCommentAttachment(
|
||||
named: "gone.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
|
||||
) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The store bracket
|
||||
|
||||
@MainActor
|
||||
@Suite("Comments ▸ authoring attachments at the store")
|
||||
struct CommentAttachmentStoreTests {
|
||||
|
||||
@Test("The store's import and remove land through the ordinary bracket")
|
||||
func theStoreBracketsBoth() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText())
|
||||
let file = try fixture.file("sources/shot.png", Data("png".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let cardID = ItemID(rawValue: Ident.card1)
|
||||
|
||||
store.importCommentAttachments([file], inCard: cardID, target: .draft)
|
||||
#expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
|
||||
|
||||
store.removeCommentAttachment(named: "shot.png", inCard: cardID, target: .draft)
|
||||
#expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
|
||||
}
|
||||
|
||||
@Test("A card that is not on the board writes nothing")
|
||||
func aVanishedCardWritesNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeCommentBoard(fixture)
|
||||
let file = try fixture.file("sources/shot.png", Data("png".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.importCommentAttachments([file], inCard: ItemID(rawValue: Ident.card4), target: .draft)
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card4)"))
|
||||
}
|
||||
|
||||
@Test("The draft read reaches the store the same way the thread read does")
|
||||
func theStoreReadsTheDraft() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.draft", commentText(body: "restored\n"))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card1))?.body == "restored\n")
|
||||
#expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card4)) == nil, "a card that is not there")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user