Build the attachments sidebar section

The card's complete file inventory: compact QuickLook-thumbnail rows
over Card.attachments — no reference tracking, subfolders tolerated
and unsurfaced — with a quiet header add affordance and the drop hint
empty state. The whole window is the file-drop surface, Edit mode
included (the editor's drag types were already filtered; now tested),
sharing the board's folder-refusal semantics literally: FinderDrop
moved verbatim into its own file so both windows run the same
partition and loss row. Dragged text still lands at the caret and is
inert elsewhere — the window delegate accepts file payloads only.
Rows open on double-click or Return, drag out their file URL, and
Remove is a bracketed write through FileManager.trashItem — the system
Trash, never a hard delete, returning the in-Trash URL so the promise
is testable; the attachment listing is the guard, so traversal and
subfolder names refuse in one line. Keyboard-native per 05: the
section is one Tab stop, arrows walk rows by name, Space toggles the
shared QuickLook panel, Backspace removes. File > Add Attachment
(shift-cmd-A) comes alive through the same import path.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 11:52:37 -04:00
parent 40c0a75c24
commit 46397c740e
16 changed files with 1823 additions and 214 deletions
+79 -4
View File
@@ -1539,6 +1539,67 @@ public enum BoardWriter: Sendable {
return BoardLoader.attachmentNames(in: cardFolder)
}
/// Moves one of a card's attachments to the **system** Trash the attachment row's Remove and
/// its twin (05-card-window.md Attachments: "context menu Open / Reveal in Finder / Remove
/// (moves to the **system** Trash, never hard-deletes )").
///
/// ### `trashItem`, and never `removeItem`
///
/// The design says *system* Trash and means it: a board's own tombstone quasi-lane is a different
/// trash with a different vocabulary (03-board-ui.md § Trash's naming constraint), and an
/// attachment removed here is recoverable exactly the way a file dragged out of a Finder window
/// is by the user, in Finder, with no help from this app. `FileManager.trashItem` is that
/// sentence; `removeItem` would be a hard delete of the user's own file, which this app never
/// does to an attachment.
///
/// ### The listing is the guard
///
/// `name` is checked against `BoardLoader.attachmentNames(in:)` **the very set the sidebar
/// shows** before anything is touched, which is what makes the whole class of "the caller
/// passed something else" unreachable in one line rather than four: a path (`../index.md`), a
/// subfolder, a hidden file, a symlink, and the empty string are all simply not in the listing,
/// and none of them can be trashed through this call. It is also `relocateLooseFiles`' re-check
/// rule applied here the caller's name is re-read against disk at write time, not trusted from
/// whenever the row was drawn.
///
/// ### A name that is no longer there is not a failure
///
/// It returns `false` and writes nothing, `relocateLooseFiles`' rule again: "the reload is the
/// authority on what is there, and a file the user deleted between the walk and the write is not
/// a failure to report". A racing an external delete of the same file is exactly that race, and
/// a banner for it would name a removal the user got anyway.
///
/// - Returns: where the file now sits **inside the Trash**, or `nil` when there was nothing to
/// move. The URL is returned rather than discarded because it is the only proof this call
/// makes that the file still exists at all the difference between the promise ("never
/// hard-deletes") and a `removeItem` that would look identical from `attachments/`.
@discardableResult
public static func removeAttachment(
named name: String,
fromCard cardFolder: URL
) throws(BoardWriteError) -> URL? {
let operation = WriteOperation.removeAttachment(filename: name)
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsUUIDShaped(cardFolder, operation: operation)
guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil }
let fileURL = cardFolder
.appendingPathComponent(attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
)
}
return trashedURL as URL?
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between
@@ -1725,6 +1786,18 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case importAttachment(filename: String)
case listAttachments
/// An attachment being moved to the **system** Trash the card window's attachment Remove and
/// its twin (05-card-window.md Attachments).
///
/// Its own case rather than a fold into `.delete`, on the vocabulary's standing reasoning and
/// then some: `.delete` is the board's *tombstone*, and this app has two trashes on purpose
/// "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
/// 'Delete'" (03-board-ui.md § Trash). A banner saying the app "couldn't delete 'shot.png'"
/// would claim the board's own trash took a hand in a file only Finder can give back.
/// `filename` is the attachment's name as the sidebar shows it.
case removeAttachment(filename: String)
case renumberChildren // order-maintenance sweep (compaction)
/// A loose file being moved out of a card folder into its `attachments/` the loose-file
@@ -1771,9 +1844,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case rawSource(title: String?)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
/// filename it already carries; `listAttachments` and `renumberChildren` name no single item.
/// on identity for the cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
/// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
/// one their banner should say; `listAttachments` and `renumberChildren` name no single item.
/// Called once, right where the operation's `readDocument` succeeds `updateIndex` itself
/// (which covers every case that funnels through it: renumber, delete, restore, style, and
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
@@ -1782,7 +1856,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.renumberChildren, .relocateLooseFile:
.removeAttachment, .renumberChildren, .relocateLooseFile:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -1823,6 +1897,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)