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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user