From 46397c740e4358d3c38fde422c31d5d44a4834da Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 11:52:37 -0400 Subject: [PATCH] Build the attachments sidebar section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppCommands.swift | 19 +- Kanban/App/CardWindowHost.swift | 59 ++- Kanban/App/FutureCommands.swift | 25 +- Kanban/LiveStore/BannerCenter.swift | 7 + Kanban/LiveStore/BoardStore.swift | 33 ++ Kanban/Storage/BoardWriter.swift | 83 +++- Kanban/UI/Board/BoardDrops.swift | 175 +------- Kanban/UI/Card/AttachmentThumbnails.swift | 180 ++++++++ Kanban/UI/Card/CardAttachments.swift | 241 +++++++++++ Kanban/UI/Card/CardAttachmentsSection.swift | 362 ++++++++++++++++ Kanban/UI/Card/CardWindowDrop.swift | 130 ++++++ Kanban/UI/Card/CardWindowMetrics.swift | 21 + Kanban/UI/Card/CardWindowView.swift | 60 ++- Kanban/UI/FinderDrop.swift | 186 ++++++++ KanbanTests/CardAttachmentsTests.swift | 452 ++++++++++++++++++++ README.md | 4 +- 16 files changed, 1823 insertions(+), 214 deletions(-) create mode 100644 Kanban/UI/Card/AttachmentThumbnails.swift create mode 100644 Kanban/UI/Card/CardAttachments.swift create mode 100644 Kanban/UI/Card/CardAttachmentsSection.swift create mode 100644 Kanban/UI/Card/CardWindowDrop.swift create mode 100644 Kanban/UI/FinderDrop.swift create mode 100644 KanbanTests/CardAttachmentsTests.swift diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift index 5623034..a3a1eaf 100644 --- a/Kanban/App/AppCommands.swift +++ b/Kanban/App/AppCommands.swift @@ -322,12 +322,15 @@ private final class DuplicateCancellation { /// will drop) disables rather than falling back to the root: revealing the wrong thing is worse /// than nothing, and only a genuinely empty selection means "the board". /// -// m6-card-window: the item's third scope — the card's folder, or the selected attachment's file -// when the attachments section is focused — adds a focused value and a branch here; the two below -// do not move. +/// **The card-window scope is the third branch**, and it is the one the attachment row's context +/// menu twins (11-command-nexus.md ▸ Context menus): "card window: the card's folder — the selected +/// attachment's file instead when the attachments section is focused". The rule itself is +/// `CardAttachments.revealURLs`, so the menu row and the row's own Reveal cannot disagree about what +/// "the selected attachment" means. struct RevealInFinderCommand: View { @FocusedValue(\.boardStore) private var store + @FocusedValue(\.cardAttachments) private var attachments @FocusedValue(\.welcomeSelection) private var selection var body: some View { @@ -338,7 +341,8 @@ struct RevealInFinderCommand: View { } /// What the item would reveal, and therefore whether it is enabled — one answer for both, the - /// codebase's usual shape. The board in front wins; the welcome branch stands when no board is. + /// codebase's usual shape. The board in front wins; the card-window branch stands when a card + /// window is; the welcome branch stands when neither is. private var urls: [URL] { if let store { let ids = store.selection.ids @@ -346,6 +350,13 @@ struct RevealInFinderCommand: View { return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot) .map { $0.folder(under: store.rootURL) } } + if let attachments { + return CardAttachments.revealURLs( + cardFolder: attachments.cardFolder, + selectedURL: attachments.selectedURL, + isSectionFocused: attachments.isFocused + ) + } guard let selection, selection.canReveal, let url = selection.url else { return [] } return [url] } diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 6ceacab..87f587a 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -139,6 +139,15 @@ struct CardWindowHost: View { /// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different /// scopes, and the Edit Body row reads both. @State private var rawSource = CardRawSourceSession() + /// This window's attachments section — the listing, the keyboard selection, and the two writes + /// it starts (05-card-window.md ▸ Attachments). Window-scoped for `CardBodyPresentation`'s + /// reason: two card windows on one board have two different selections, and the menu bar reaches + /// the frontmost one through the focus system. + @State private var attachments = CardAttachments() + /// This window's thumbnail memory. Held here rather than in the section so it survives every + /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail + /// on every reload (`AttachmentThumbnailCache`). + @State private var thumbnails = AttachmentThumbnailCache() /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not /// flush; cleared by the resolution that lets the close resume. @State private var isClosePending = false @@ -210,6 +219,9 @@ struct CardWindowHost: View { // reads it too — "View ▸ Edit Body (⌘E) disables while source mode is active" // (05-card-window.md ▸ Raw source outlet). .focusedSceneValue(\.cardRawSource, rawSource) + // File ▸ Add Attachment… (⇧⌘A) and File ▸ Reveal in Finder's card-window scope reach the + // frontmost card window the same way (11-command-nexus.md). + .focusedSceneValue(\.cardAttachments, attachments) // The raw-source outlet's detailed alert, presented over this window — a validation // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather // than inside the editor because the second of those fires while source mode is still @@ -266,12 +278,32 @@ struct CardWindowHost: View { rawSource: rawSource, // "Under the read-only lock the controls disable in place — an in-content mutation // menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and - // the store's own lock is the whole predicate. + // the store's own lock is the whole predicate — and it is the attachments section's + // predicate too ("the attachment row's ⌫/Remove shares the posture"). isEditable: !store.isReadOnly, + attachments: attachments, + thumbnails: thumbnails, + fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), onToggleTask: { offset, checked in store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) } ) + // **The listing is the snapshot's, republished** — `Card.attachments`, which the loader + // fills from `attachments/`'s top-level files in Finder order. Every write in the + // section is bracketed, so the reload that refreshes this arrives by itself and the + // section never lists a directory of its own (05 ▸ Attachments; the one-way flow). + .onChange(of: placement.card.attachments, initial: true) { _, names in + attachments.names = names + } + .onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in + // Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session + // folder rename moves the board, and rows resolving against where it used to be + // would open nothing. + attachments.cardFolder = folder + } + .onChange(of: store.isReadOnly, initial: true) { _, locked in + attachments.isEditable = !locked + } } else { // Nothing to render and nothing worth animating: this window is on its way out. Color.clear @@ -386,6 +418,31 @@ struct CardWindowHost: View { store: store, cardID: cardID ) + Self.configureAttachments(attachments, store: store, cardID: cardID) + } + + /// Points the attachments section at its card — **the one place Add Attachment… and Remove + /// learn which card they act on** (05-card-window.md ▸ Attachments). + /// + /// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)` + /// is the *same* call the board window's Finder drop makes, so a file added through ⇧⌘A, through + /// the header's plus, through a drop anywhere in this window, and through a drop on the card's + /// face on the board all take one path — one collision rename, one set of banners, one commit + /// shape. There is deliberately no card-window import of its own to keep in step with it. + /// + /// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the + /// board window has gone should write nothing rather than resurrect a released store. + /// + /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: + /// the target resolution is invisible in a running window until it is wrong, and this shape is + /// what lets a test drive the real wiring rather than a re-typed copy of it. + static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) { + attachments.importFiles = { [weak store] urls in + store?.importAttachments(urls, toCard: cardID) + } + attachments.removeFile = { [weak store] name in + store?.removeAttachment(named: name, fromCard: cardID) + } } /// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift index a7ad26c..e2ef27a 100644 --- a/Kanban/App/FutureCommands.swift +++ b/Kanban/App/FutureCommands.swift @@ -52,27 +52,10 @@ struct SaveAsTemplateCommand: View { } } -// MARK: - File ▸ Add Attachment… - -/// File ▸ Add Attachment… (⇧⌘A) — card window only (11-command-nexus.md). -/// -// m6-card-window: the menu-bar twin of the attachments section's quiet add affordance -// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope -// alone — a card window in front, the read-only lock aside — `BoardInfoCommand`'s shape for its own -// scope-only item. -// -// m5-context-menus, m6-card-window: the attachment row's own context menu is a second thing this -// milestone owes — Open, Remove (system Trash), Reveal in Finder (11-command-nexus.md ▸ Context -// menus' Attachment row), twinning the focused section's grammar keys (Return open / ⌫ remove — 05 -// ▸ Attachments) and Reveal in Finder's attachments-focused scope, exactly the way this milestone's -// card and lane menus twin their own grammar and menu-bar commands: no new store method, no parallel -// implementation. There is no row view to hang a `.contextMenu` off yet, so nothing scaffolds here -// beyond this marker. -struct AddAttachmentCommand: View { - var body: some View { - FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command]) - } -} +// File ▸ Add Attachment… (⇧⌘A) was the scaffold here and is now live, beside the focused value it +// reads (`AddAttachmentCommand`, in `CardAttachments.swift`) — the diff this file predicts: the +// title and the chord did not move, the validation and the action filled in. The attachment row's +// context menu (Open / Remove / Reveal in Finder) shipped with it, on the row view that now exists. // MARK: - Edit ▸ Find Next / Find Previous diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 3d98ca5..9b42c93 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -631,6 +631,13 @@ public final class BannerCenter { "Couldn't import '\(filename)'" case .listAttachments: "Couldn't read this card's attachments" + case let .removeAttachment(filename): + // **Finder's phrasing, deliberately** — and the one place in this app that is allowed + // it. The naming constraint above reserves "move to the Trash" for the *system* Trash, + // and this is the operation that uses it: the file really did go (or fail to go) where + // Finder's own ⌘⌫ sends things, so saying anything else — "remove", "delete" — would + // describe the board's quasi-lane instead and promise the wrong recovery. + "Couldn't move '\(filename)' to the Trash" case .renumberChildren: "Couldn't renumber cards" case let .relocateLooseFile(filename): diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index b3a1ec3..105bc12 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -2067,6 +2067,39 @@ public final class BoardStore { } } + // MARK: - Removing an attachment + + /// Moves one of a card's attachments to the **system** Trash — the card window attachment row's + /// Remove, its ⌫ twin, and nothing else (05-card-window.md ▸ Attachments). + /// + /// **An ordinary bracketed write, which is the whole point of it being here** rather than a + /// `FileManager` call in the view: it mutates the card's folder, so the churn has to round back + /// as one *app-mediated* reload (the echo the watcher would otherwise read as a foreign edit), + /// it has to refuse under the read-only lock like every other mutation (`performWrite`'s gate), + /// and its failures have to reach the banner strip like every other write's. On git boards it + /// is also one commit, for free, for the same reason. + /// + /// The guards are `importAttachments`' exactly, and its inverse in every way: **liveness is + /// ancestor-walked** (`liveItem`), so a card under a tombstoned lane is as gone as a deleted one + /// and its attachments are not removable from a window that is dismissing itself in the same + /// breath; a lane id is refused because attachments belong to cards. Which *file* may go is + /// `BoardWriter.removeAttachment`'s listing check, and a name that is no longer there is a + /// silent no-op rather than a failure — the reload is the authority on what the card has. + public func removeAttachment(named name: String, fromCard cardID: ItemID) { + guard !name.isEmpty, + let item = Self.liveItem(cardID, in: snapshot), + let card = item.cardID + else { return } + + let folder = rootURL + .appendingPathComponent(item.laneID.rawValue, isDirectory: true) + .appendingPathComponent(card.rawValue, isDirectory: true) + + try? performWrite { () throws(BoardWriteError) -> Void in + _ = try BoardWriter.removeAttachment(named: name, fromCard: folder) + } + } + // MARK: - The loose-file carve-out /// Moves every loose file the last applied snapshot found beside a card's `index.md` into that diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 3e58ff3..22be831 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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) diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 59d83b8..014572e 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -606,177 +606,10 @@ struct BoardDropContext { } } -// MARK: - Loading what Finder dropped - -/// The one place an `NSItemProvider` from an external drag is unwrapped into a file URL. -enum FileDropLoading { - - /// The file URL a dropped provider carries, or `nil` when it carries none. - /// - /// **`loadItem` on `.fileURL`, not `loadInPlaceFileRepresentation`.** A `public.file-url` item is - /// what Finder actually puts on the dragging pasteboard, and the drag itself is what grants the - /// sandbox the extension to read it — the caller opens the scope and copies. The in-place - /// representation would hand back a URL valid only for the duration of its own completion block, - /// forcing the copy to happen off the main actor inside a callback, for no benefit here. - /// - /// The completion fires on an arbitrary queue — never assume the main actor — so this is a plain - /// continuation wrapper; a `CheckedContinuation` resumes from any queue whatever isolation the - /// call site started from. Both shapes a `.fileURL` item arrives in are accepted: the `Data` - /// encoding it normally takes, and a bare `URL`. - /// - /// `@MainActor` for a concurrency reason rather than a behavioural one: the provider comes off a - /// `DropInfo` on the main actor and is not `Sendable`, so a nonisolated entry point would be - /// *sending* it across domains. Staying on the actor it came from keeps the hand-off to the - /// completion handler — which fires wherever AppKit likes — the only crossing there is. - @MainActor - static func url(from provider: NSItemProvider) async -> URL? { - await withCheckedContinuation { continuation in - provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { - continuation.resume(returning: url) - } else if let url = item as? URL { - continuation.resume(returning: url) - } else { - continuation.resume(returning: nil) - } - } - } - } -} - -// MARK: - Files in, folders out - -/// What a Finder drag is carrying and where it lands — **the folder refusal, both halves** -/// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover … the attachment model is flat -/// top-level files, and the importer refuses directories by design"). -/// -/// ### Why the payload is read twice -/// -/// The rule is a *hover* rule, and hover has only the providers' declared types to go on: a file URL -/// is not resolved until the drop, and resolving one during hover is neither offered nor affordable. -/// So the drag is read twice, and the two reads have different jobs: -/// -/// - **At hover, from `registeredTypeIdentifiers`** — Finder registers the concrete UTI beside -/// `public.file-url`, so a folder announces itself as `public.folder` before anything is loaded. -/// That is what makes a folders-only drag refuse *at the cursor*: no highlight, no shadows, the -/// incompatible-payload read (`BoardDropContext.acceptsFileDrop`). -/// - **At the drop, from the URLs themselves** — `FinderDrop.partition`, which is a filesystem fact -/// rather than a declaration and therefore the authority. It is what actually decides what gets -/// written. -/// -/// **Unknown at hover is treated as a file**, deliberately: a provider that registers only -/// `public.file-url` and no concrete type — a synthetic drag, or an unusual source — cannot be -/// classified until its URL resolves, and the optimistic read means such a drag still engages, still -/// shows its shadows, and is sorted out authoritatively at the drop. The pessimistic read would make -/// an ordinary file drag silently dead, which is the far worse failure. -/// -/// **A package is a directory.** Conformance to `public.directory` — not equality with -/// `public.folder` — is the test, so a `.app`, an `.rtfd`, or any other bundle is refused exactly as -/// a plain folder is: the flat top-level attachment model has no more room for one than for the -/// other, and `isDirectory(at:)` says the same thing at the drop. -enum FinderDrop { - - // MARK: The hover read — declared types - - /// Whether a provider's registered types describe a directory (folders and packages alike). - /// - /// An identifier the system does not know, and an empty list, are *not* directories: this is the - /// optimistic side of the unknown-at-hover rule above. - nonisolated static func isDirectory(typeIdentifiers: [String]) -> Bool { - typeIdentifiers.contains { identifier in - UTType(identifier)?.conforms(to: .directory) ?? false - } - } - - /// How many of these providers are importable — the file count every hover-time proposal is - /// sized by, and `0` is the refusal that keeps a folders-only drag from ever engaging. - /// - /// `@MainActor` for the same reason `FileDropLoading.url(from:)` is: an `NSItemProvider` off a - /// `DropInfo` is not `Sendable`, so it stays on the actor it arrived on. - @MainActor - static func importableCount(_ providers: [NSItemProvider]) -> Int { - providers.filter { !isDirectory(typeIdentifiers: $0.registeredTypeIdentifiers) }.count - } - - /// How many shadows the create path draws — **one nominal-height shadow per incoming file** - /// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), **floored - /// at one**: "when macOS withholds item counts during hover the count floors at one shadow, the - /// commit unaffected". - /// - /// The floor is a *hover* concession and nothing more. A drag whose providers cannot be counted - /// still opens a slot the user can aim at, and the write is `FinderDrop.land`'s — resolved URLs, - /// partitioned against the filesystem — so one shadow standing in for three files costs the drop - /// nothing. It is read from the drag on every sample rather than captured at `dropEntered`: a - /// delegate can be entered without this window ever having seen the enter callback (single-target - /// dispatch hands the session to whichever region is deepest), and a count that was never set - /// would draw the wrong number of shadows. - @MainActor - static func shadowCount(_ providers: [NSItemProvider]) -> Int { - max(1, importableCount(providers)) - } - - // MARK: The drop read — the filesystem - - /// Whether `url` is a directory, as the filesystem answers it — the authoritative read. - /// - /// `resourceValues` first (the real answer, packages included), `fileExists` as the fallback for - /// a URL whose resource values cannot be read, and the purely lexical `hasDirectoryPath` last, - /// for a source that has already vanished between the drag and the drop. - nonisolated static func isDirectory(at url: URL) -> Bool { - if let flag = try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory { return flag } - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) { - return isDirectory.boolValue - } - return url.hasDirectoryPath - } - - /// Splits a dropped set into what will be written and what will be named as skipped, preserving - /// input order in both halves — the create path mints its cards in drop order, and the order the - /// user dropped in is the only order there is. - nonisolated static func partition(_ urls: [URL]) -> (files: [URL], folders: [URL]) { - var files: [URL] = [] - var folders: [URL] = [] - for url in urls { - if isDirectory(at: url) { folders.append(url) } else { files.append(url) } - } - return (files, folders) - } - - // MARK: The write - - /// The drop's **write half**: the files land where the highlight or the shadows showed, and the - /// folders are named in a loss row rather than attempted. - /// - /// **No card is ever minted for an import that cannot succeed** (04-interactions.md): the folders - /// are gone before `createCards` sees the list, so the create path only ever fires with files — - /// "the mint-fail-remove dance is gone" for this reason, not because the store stopped doing it. - /// `BoardStore.createCards` still removes a card whose import failed for a *genuine* reason (an - /// unreadable source, a full disk), and `BoardWriter.importAttachments` still refuses a directory - /// outright: that throw stays as the model layer's backstop for every other caller, and folders - /// simply never reach it from here. - /// - /// **Zero files is a valid arrival, and writes nothing.** The hover refusal means a folders-only - /// drag normally never gets here at all; a payload whose types were unknown at hover can, and the - /// honest answer is the loss row alone — no write, no empty card, nothing to undo. - /// - /// A **loss row, not a failure one-shot** (02-architecture.md § the banner vocabulary): nothing - /// failed here. The files the user dropped arrived; the folders were never things this app could - /// take, and `postSkippedFolders` is silent at zero, so an all-files drop says nothing at all. - @MainActor - static func land(_ urls: [URL], landing: FileDropTarget.Landing, into store: BoardStore) { - let (files, folders) = partition(urls) - if !files.isEmpty { - switch landing { - case let .attach(cardID): - store.importAttachments(files, toCard: cardID) - case let .create(laneID, index): - store.createCards(fromFiles: files, inLane: laneID, at: index) - } - } - store.banners.postSkippedFolders(count: folders.count) - } -} +// The Finder-drag payload rules this file used to hold — `FileDropLoading` and `FinderDrop` — moved +// to `Kanban/UI/FinderDrop.swift` verbatim when the card window's whole-window attachment drop +// (05-card-window.md ▸ Attachments) became their second caller. Everything below still calls them by +// the same names; only their file changed. // MARK: - Drop delegates diff --git a/Kanban/UI/Card/AttachmentThumbnails.swift b/Kanban/UI/Card/AttachmentThumbnails.swift new file mode 100644 index 0000000..21b8bd2 --- /dev/null +++ b/Kanban/UI/Card/AttachmentThumbnails.swift @@ -0,0 +1,180 @@ +import AppKit +import Observation +import QuickLookThumbnailing + +// MARK: - AttachmentThumbnailKey + +/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which +/// bytes** (05-card-window.md ▸ Attachments — "small QuickLook thumbnail (Finder-icon fallback)"). +/// +/// The third component is what makes the cache honest rather than merely fast. A card folder is a +/// live view over a directory anyone may write to (02-architecture.md), so a cache keyed on the path +/// alone would happily show a picture of a file that has since been replaced. Modification date +/// *and* size, because either alone misses a case: a same-second rewrite keeps the date, and an edit +/// that preserves the length keeps the size. +/// +/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd — it went +/// away between the listing and the render, a volume dropped — keys as `(nil, nil)` and simply +/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than +/// a second error surface. +struct AttachmentThumbnailKey: Hashable, Sendable { + + /// The half a *view* can name without touching the disk — the path and the size it is drawn at. + /// + /// The cache is looked up by this during a render and completed by the full key in a task, + /// which is the whole reason it is a type: a render must never `stat`, and a stale-check must + /// never be skipped. + struct Slot: Hashable, Sendable { + let path: String + let side: Int + + /// The row's thumbnail is square and **buckets to whole points**: the row height derives + /// from the body font's metrics (`CardWindowMetrics`), which can land on a fraction, and a + /// thumbnail regenerated because the row grew by a third of a point would be a cache that + /// never hits. + init(path: String, side: CGFloat) { + self.path = path + self.side = max(1, Int(side.rounded())) + } + } + + let slot: Slot + let modified: Date? + let size: Int64? +} + +// MARK: - AttachmentThumbnailCache + +/// One card window's thumbnail memory — **per window**, held by `CardWindowHost` and read by the +/// attachments section beneath it. +/// +/// ### Why the window +/// +/// Per *row* would defeat the point: rows are rebuilt on every snapshot the store applies (a body +/// edit in another window, a watcher reload, a lane move), and a cache that died with the view would +/// regenerate every thumbnail each time. Per *app* would outlive the thing it is caching — a card +/// window closing is the natural moment to forget its files. The window is the scope where "the +/// files I am looking at" is true. +/// +/// ### What it does not do +/// +/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a +/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is +/// deliberately no invalidation path to keep honest — the same posture `Card.attachments` takes. +/// +/// This is the **minimal** re-creation of a cache the board face once had and that went with the +/// carousel (commit `1020d9f`, sole consumer): small square rows instead of page-sized pictures, and +/// nothing about paging, so the cap and the drawn size are both an order smaller. +@MainActor +@Observable +final class AttachmentThumbnailCache { + + /// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a + /// card may hold hundreds of attachments; a plain insertion-ordered drop rather than a recency + /// policy because a sidebar's access pattern is "the rows on screen", which is the recent set. + static let limit = 128 + + /// The resolved stamp for each drawn slot — the render-time half of the lookup. + private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:] + + private var images: [AttachmentThumbnailKey: CGImage] = [:] + + /// Insertion order over `images`, for the cap. + private var order: [AttachmentThumbnailKey] = [] + + /// Keys QuickLook declined — an unknown type, an unreadable one. Remembered so a + /// non-previewable attachment costs one generation attempt per version of itself rather than one + /// per redraw; the row shows its Finder icon and stops asking. + private var unpreviewable: Set = [] + + /// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one + /// is filled *during* a render, because an icon is the fallback a row draws while its thumbnail + /// is still being made, and a tracked write there would invalidate the view that just read it. + /// Nothing depends on an icon changing — `NSWorkspace` answers the same image for the same path + /// until the app is relaunched. + @ObservationIgnored private var icons: [String: NSImage] = [:] + + // MARK: - Reading, during a render + + /// This slot's thumbnail, or `nil` when there is not one *yet* — the two dictionary reads a + /// render is allowed to do. A miss is not a failure; it is the icon fallback's cue. + func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? { + guard let key = keys[slot] else { return nil } + return images[key] + } + + /// The file's Finder icon — 05's "Finder-icon fallback", shown while a thumbnail is being + /// generated and kept for anything QuickLook cannot preview. + /// + /// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder + /// itself shows, custom icons and application bundles included, and an attachment that looks + /// different in Finder than in the sidebar would be the app disagreeing with the substrate it is + /// a view over. + func icon(forFileAt url: URL) -> NSImage { + if let cached = icons[url.path] { return cached } + let icon = NSWorkspace.shared.icon(forFile: url.path) + icons[url.path] = icon + return icon + } + + // MARK: - Filling, off the render + + /// Resolves this slot's key and generates its thumbnail if that key has none — the whole of the + /// cache's write side, called from a row's `.task` and never from a body. + /// + /// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is + /// waiting on this task, and the generation because it is a round trip to a QuickLook extension + /// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the + /// generator hands out is not, and never leaves the function that made it. + func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, scale: CGFloat) async { + let key = await Self.resolve(slot, url: url) + keys[slot] = key + guard images[key] == nil, !unpreviewable.contains(key) else { return } + + let side = CGFloat(slot.side) + guard let image = await Self.generate(url: url, size: CGSize(width: side, height: side), scale: scale) else { + unpreviewable.insert(key) + return + } + remember(image, for: key) + } + + private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) { + if images.updateValue(image, forKey: key) == nil { + order.append(key) + } + while order.count > Self.limit { + images.removeValue(forKey: order.removeFirst()) + } + } + + private nonisolated static func resolve( + _ slot: AttachmentThumbnailKey.Slot, + url: URL + ) async -> AttachmentThumbnailKey { + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + return AttachmentThumbnailKey( + slot: slot, + modified: values?.contentModificationDate, + size: values?.fileSize.map(Int64.init) + ) + } + + /// One QuickLook request, at the row's own size. `.thumbnail` alone rather than `.all`: the icon + /// representations QuickLook would fall back to are exactly what `icon(forFileAt:)` already + /// draws, and taking them here would cache a decorated icon under a thumbnail's key and never + /// try for a real one again. `iconMode` is off for the same reason — the row wants the picture, + /// not a page-curled document. + private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? { + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: size, + scale: scale, + representationTypes: .thumbnail + ) + request.iconMode = false + guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request) + else { return nil } + return representation.cgImage + } +} diff --git a/Kanban/UI/Card/CardAttachments.swift b/Kanban/UI/Card/CardAttachments.swift new file mode 100644 index 0000000..445bbfe --- /dev/null +++ b/Kanban/UI/Card/CardAttachments.swift @@ -0,0 +1,241 @@ +import AppKit +import Observation +import SwiftUI + +// MARK: - The window's attachments, as a handle + +/// One card window's attachments section, reduced to what things *outside* it need: which files +/// there are, which row the keyboard is on, and the two writes the section can start +/// (05-card-window.md ▸ Attachments). +/// +/// `CardBodyPresentation`'s shape and for its reason — one per window, `@State` in the host, +/// published through the focus system so **menu items** (File ▸ Add Attachment…, ⇧⌘A; File ▸ Reveal +/// in Finder's third scope) can reach the frontmost card window without anyone keeping a +/// which-window-is-key register. It is deliberately not on `BoardStore`: the store is the *board's*, +/// shared by every window on it, and two card windows open on two cards of one board have two +/// different selections. +/// +/// ### What it is not +/// +/// It is **not** the listing's source of truth. `names` is republished from every snapshot the store +/// applies (`Card.attachments`, which the loader fills from `attachments/`'s top-level files in +/// Finder order), so the section shows what the last reload found and nothing else — the one-way +/// flow, with no second listing able to disagree with the board face's chip. Nothing here reads a +/// directory; nothing here writes a file. Both writes go out through the seams below, which the host +/// fills with the store's own bracketed methods. +@MainActor +@Observable +public final class CardAttachments { + + /// The card's own folder — `//`. `nil` until the window has joined its board, + /// which is also exactly while there is nothing to add an attachment *to*. + public var cardFolder: URL? + + /// The files the section shows, in Finder order — `Card.attachments`, straight from the + /// snapshot. **Every top-level file of `attachments/`**, body-embedded ones included: "the + /// section is the card's complete file inventory, no reference-tracking magic" (05). + public var names: [String] = [] { + didSet { selected = Self.settle(selected, was: oldValue, is: names) } + } + + /// Whether the section's mutations are offered at all — `!store.isReadOnly`. Under the lock the + /// add affordance, Remove and ⌫ disable in place, which is 02-architecture.md's every-entry-point + /// predicate applied to this section (05 names it: "the attachment row's ⌫/Remove shares the + /// posture"). + public var isEditable = false + + /// Which row the keyboard is on, by name — names are unique within one folder, so a name is a + /// stabler identity than an index across a reload that inserted a file above it. + public var selected: String? + + /// Whether the section currently holds keyboard focus. Read by File ▸ Reveal in Finder, whose + /// card-window scope is "the card's folder — the selected attachment's file instead when the + /// attachments section is focused" (11-command-nexus.md). + public var isFocused = false + + /// Imports files into this window's card — filled by the host with `BoardStore + /// .importAttachments(_:toCard:)`, the **same** store method the board window's Finder drop + /// rides. One import path, one set of banners, one Finder-style collision rename. + public var importFiles: (([URL]) -> Void)? + + /// Moves one attachment to the system Trash — filled by the host with `BoardStore + /// .removeAttachment(named:fromCard:)`. + public var removeFile: ((String) -> Void)? + + public init() {} + + // MARK: - Derived + + /// Where `name` lives on disk, or `nil` when this window has no folder yet. + public func url(for name: String) -> URL? { + guard let cardFolder, names.contains(name) else { return nil } + return cardFolder + .appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true) + .appendingPathComponent(name) + } + + /// The selected row's file, when there is one. + public var selectedURL: URL? { + selected.flatMap { url(for: $0) } + } + + // MARK: - The two writes + + /// File ▸ Add Attachment… (⇧⌘A) and the header's quiet add affordance — **one act with two + /// pointers at it** (11-command-nexus.md: the affordance "is a pointer twin of File ▸ Add + /// Attachment…, no separate behavior"). + /// + /// A cancelled panel imports nothing and says nothing; a panel that returns files hands them to + /// the very same store method a whole-window drop uses. + public func add() { + guard isEditable, cardFolder != nil else { return } + let urls = Self.chooseFiles() + guard !urls.isEmpty else { return } + + // The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `start…` answers false for a + // URL that carries no scope of its own, so only the ones that opened are closed again. + let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } + defer { for url in scoped { url.stopAccessingSecurityScopedResource() } } + importFiles?(urls) + } + + /// Remove / ⌫ — the system Trash, never a hard delete (05 ▸ Attachments). + public func remove(_ name: String) { + guard isEditable, names.contains(name) else { return } + removeFile?(name) + } + + // MARK: - Row actions that are not writes + + /// Double-click, Return, and the context menu's Open: the file's default app (05 ▸ Attachments). + /// Enabled under the read-only lock like every other read — opening a file mutates nothing here. + public func open(_ name: String) { + guard let url = url(for: name) else { return } + NSWorkspace.shared.open(url) + } + + public func reveal(_ name: String) { + guard let url = url(for: name) else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + // MARK: - Keyboard + + /// ↑/↓ over the rows. Moving with nothing selected selects the first row (going down) or the + /// last (going up), which is what makes the section usable the instant it takes focus. + public func moveSelection(by delta: Int) { + selected = Self.moved(selected, by: delta, in: names) + } + + // MARK: - The pure rules + + /// Where ↑/↓ lands: **clamped, never wrapping** — a list is not a carousel, and an arrow at the + /// end of a short list should not silently jump to the other end of it. + /// + /// A selection that is not in `names` (a file removed under the cursor) is treated as no + /// selection, so the next arrow re-enters the list from its edge. + public nonisolated static func moved(_ selected: String?, by delta: Int, in names: [String]) -> String? { + guard !names.isEmpty else { return nil } + guard let selected, let index = names.firstIndex(of: selected) else { + return delta < 0 ? names.last : names.first + } + return names[min(max(0, index + delta), names.count - 1)] + } + + /// Where the selection goes when the listing changes underneath it — **the row that took its + /// place**, which is the behaviour every list in macOS has after a delete: remove the third of + /// five files and the selection lands on the new third, not on nothing and not on the top. + /// + /// A selection that survived the change keeps its row (the common case: another window's import + /// added a file elsewhere). An empty listing selects nothing. A selection that was never set + /// stays unset — a reload must not select a row the user did not. + public nonisolated static func settle(_ selected: String?, was previous: [String], is names: [String]) -> String? { + guard let selected else { return nil } + if names.contains(selected) { return selected } + guard !names.isEmpty, let index = previous.firstIndex(of: selected) else { return nil } + return names[min(index, names.count - 1)] + } + + /// What File ▸ Reveal in Finder reveals in a card window: **the selected attachment's file when + /// the attachments section is focused, the card's folder otherwise** (11-command-nexus.md). + /// + /// `[]` — which is the row's `disabled` condition — only when there is no card folder at all: a + /// window on its way out. A focused section with nothing selected still reveals the card, which + /// is the honest fallback rather than a row that goes dead when the user tabs into a list. + public nonisolated static func revealURLs( + cardFolder: URL?, + selectedURL: URL?, + isSectionFocused: Bool + ) -> [URL] { + if isSectionFocused, let selectedURL { return [selectedURL] } + guard let cardFolder else { return [] } + return [cardFolder] + } + + // MARK: - The panel + + /// The multi-select open panel behind Add Attachment… — **every file type**, because a card's + /// `attachments/` takes anything (01-storage-format.md § Attachments) and a filter here would be + /// this app deciding what the user may keep beside their card. + /// + /// Directories are not choosable, which is the panel's own spelling of the same refusal a + /// folder drop gets (`FinderDrop`): the attachment model is flat top-level files. + private static func chooseFiles() -> [URL] { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.resolvesAliases = true + panel.prompt = "Add" + panel.message = "Choose files to attach to this card." + guard panel.runModal() == .OK else { return [] } + return panel.urls + } +} + +// MARK: - File ▸ Add Attachment… + +/// File ▸ Add Attachment… (⇧⌘A) — card window only (11-command-nexus.md; 05-card-window.md ▸ +/// Attachments). +/// +/// The diff `FutureCommands` predicted, exactly: the title and the chord did not move, the +/// validation and the action filled in. +/// +/// Validation is **scope plus the lock**. With no card window in front there is no `cardAttachments` +/// focused value and the row disables; with the board read-only it disables too, because this is a +/// mutation and 02-architecture.md's every-entry-point predicate covers menu rows as much as +/// affordances. (Contrast Edit Body, which is not a mutation and stays live under the lock.) +struct AddAttachmentCommand: View { + + @FocusedValue(\.cardAttachments) private var attachments + + /// The row's validation, as a value a test can hold — `EditBodyCommand.isEnabled`'s shape, for + /// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar. + static func isEnabled(_ attachments: CardAttachments?) -> Bool { + guard let attachments else { return false } + return attachments.isEditable && attachments.cardFolder != nil + } + + var body: some View { + Button("Add Attachment…") { + attachments?.add() + } + .keyboardShortcut("a", modifiers: [.shift, .command]) + .disabled(!Self.isEnabled(attachments)) + } +} + +// MARK: - The focused value + +/// The focused card window's attachments section, beside `FocusedValues.cardBody` — see +/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way. +struct FocusedCardAttachmentsKey: FocusedValueKey { + typealias Value = CardAttachments +} + +extension FocusedValues { + var cardAttachments: CardAttachments? { + get { self[FocusedCardAttachmentsKey.self] } + set { self[FocusedCardAttachmentsKey.self] = newValue } + } +} diff --git a/Kanban/UI/Card/CardAttachmentsSection.swift b/Kanban/UI/Card/CardAttachmentsSection.swift new file mode 100644 index 0000000..5bd5bdf --- /dev/null +++ b/Kanban/UI/Card/CardAttachmentsSection.swift @@ -0,0 +1,362 @@ +import AppKit +import Quartz +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - The section header + +/// A stacked small-caps header over a sidebar section (05-card-window.md ▸ The attributes sidebar: +/// "Stacked sections under small-caps headers"), with room for one quiet trailing affordance. +/// +/// Shared by all five sections rather than restated in each: the attachments section is the only one +/// with an accessory today, and the header's type, weight and rule have to stay identical across the +/// stack or the accessory would be the reason one section looks different from the rest. +struct CardSidebarSectionHeader: View { + + let title: String + @ViewBuilder var accessory: Accessory + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(title) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + accessory + } + Divider() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension CardSidebarSectionHeader where Accessory == EmptyView { + init(title: String) { + self.init(title: title) { EmptyView() } + } +} + +// MARK: - CardAttachmentsSection + +/// The sidebar's **Attachments** section (05-card-window.md ▸ Attachments). +/// +/// ### What it shows, and why it does not look +/// +/// Every top-level file of `attachments/`, in Finder order, **including files also embedded in the +/// body** — "the section is the card's complete file inventory, no reference-tracking magic; an +/// image appearing in both places is honest, not a bug". Subfolders are tolerated and not surfaced. +/// +/// The list itself is `Card.attachments` off the snapshot, republished into `CardAttachments.names` +/// by the host. This view never lists a directory: the loader did that on the last reload, the board +/// face's chip reads the very same field, and a second listing here would be a surface able to +/// disagree with the first. Every write in this section is bracketed (`performWrite`), so the reload +/// that refreshes the list is app-mediated and arrives by itself. +/// +/// ### Keyboard-native, which is what is new in the rewrite +/// +/// "The section is focusable; arrows move between rows, **Space QuickLooks** the selected row, +/// Return opens it, ⌫ removes it" — the pathfinder's strip was pointer-only. The substrate is +/// SwiftUI's own focus system (`focusable` + `@FocusState` + `onKeyPress`) over one focusable +/// container, rather than an `NSTableView` or a row-per-focusable list: 05 says *the section* is +/// focusable, one focus stop is what a Tab user wants out of a five-section sidebar, and the rest of +/// this window is already SwiftUI. The board's keyboard grammar is a different window's and shares +/// nothing here. +struct CardAttachmentsSection: View { + + let attachments: CardAttachments + let thumbnails: AttachmentThumbnailCache + + /// Whether the section has the keyboard. Mirrored into `CardAttachments.isFocused` because File + /// ▸ Reveal in Finder's card-window scope turns on it. + @FocusState private var isFocused: Bool + + @Environment(\.displayScale) private var displayScale + + private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + private var names: [String] { attachments.names } + + var body: some View { + VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) { + CardSidebarSectionHeader(title: "Attachments") { addAffordance } + + if names.isEmpty { + emptyHint + } else { + rows + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: isFocused, initial: true) { _, focused in + attachments.isFocused = focused + } + } + + // MARK: - Header + + /// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no + /// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row + /// does rather than opening a panel of its own. + /// + /// Disabled under the read-only lock, where the menu row is disabled too: 02-architecture.md's + /// every-entry-point predicate does not care which entry point. + private var addAffordance: some View { + Button { + attachments.add() + } label: { + Image(systemName: "plus") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!AddAttachmentCommand.isEnabled(attachments)) + .help("Add Attachment…") + .accessibilityLabel("Add Attachment") + } + + // MARK: - Empty + + /// "Empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…, + /// ⇧⌘A)" — the section never disappears, because the drop surface it advertises is the whole + /// window and a hint the user cannot find teaches nothing. + private var emptyHint: some View { + Text("Drop files anywhere, or File ▸ Add Attachment… (⇧⌘A)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Rows + + private var rows: some View { + VStack(alignment: .leading, spacing: 1) { + ForEach(names, id: \.self) { name in + row(name) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + // One focus stop for the whole list — 05's "the section is focusable". + .focusable() + .focused($isFocused) + .onKeyPress(.upArrow) { attachments.moveSelection(by: -1); return .handled } + .onKeyPress(.downArrow) { attachments.moveSelection(by: 1); return .handled } + .onKeyPress(.space) { quickLookSelected(); return .handled } + .onKeyPress(.return) { openSelected(); return .handled } + // ⌫, and only ⌫: 05 gives the section one destructive key and the board's own delete + // grammar is a different window's. + .onKeyPress(.delete) { removeSelected(); return .handled } + .accessibilityLabel("Attachments") + } + + @ViewBuilder + private func row(_ name: String) -> some View { + let url = attachments.url(for: name) + let isSelected = attachments.selected == name + + AttachmentRow( + name: name, + url: url, + isSelected: isSelected, + isSectionFocused: isFocused, + thumbnails: thumbnails, + pointSize: pointSize, + displayScale: displayScale + ) + .contentShape(Rectangle()) + // Double-click opens, a single click selects (05 ▸ Attachments; the window's click grammar, + // where clicking selects and never edits). The two-count gesture is declared first so + // SwiftUI gives it the chance to claim the second click. + .onTapGesture(count: 2) { + isFocused = true + attachments.selected = name + attachments.open(name) + } + .onTapGesture { + isFocused = true + attachments.selected = name + } + // **Rows drag out their file URL** (05 ▸ Attachments; 11-command-nexus.md ▸ Pointer-only + // affordances) — which is what makes drag-to-Finder and drag-into-another-app work with no + // export path of this app's own. An empty provider for a row whose file has gone refuses the + // drag rather than starting one that would resolve to nothing. + .onDrag { + guard let url else { return NSItemProvider() } + return NSItemProvider(contentsOf: url) ?? NSItemProvider() + } + .contextMenu { menu(for: name) } + } + + /// The attachment row's context menu — **Open, Reveal in Finder, Remove** (11-command-nexus.md ▸ + /// Context menus), twins of the focused section's grammar keys (Return / ⌫) and of File ▸ Reveal + /// in Finder in its attachments-focused context. No new store method, no parallel + /// implementation: every row here calls exactly what the keyboard calls. + /// + /// It acts on **its own row**, not on the selection, which is what makes a right-click on an + /// unselected row unambiguous without a select-first dance. + @ViewBuilder + private func menu(for name: String) -> some View { + Button("Open") { attachments.open(name) } + Button("Reveal in Finder") { attachments.reveal(name) } + Divider() + Button("Remove") { attachments.remove(name) } + .disabled(!attachments.isEditable) + } + + // MARK: - The grammar keys + + private func openSelected() { + guard let selected = attachments.selected else { return } + attachments.open(selected) + } + + private func removeSelected() { + guard let selected = attachments.selected else { return } + attachments.remove(selected) + } + + /// **Space QuickLooks the selected row** (05 ▸ Attachments) — Finder's own key, and Finder's own + /// panel: `QLPreviewPanel` previews every row of the section with the selected one showing, so + /// the panel's ←/→ walk the card's attachments exactly as it walks a Finder selection. + private func quickLookSelected() { + guard let selected = attachments.selected, + let index = names.firstIndex(of: selected) + else { return } + AttachmentQuickLook.shared.toggle( + urls: names.compactMap { attachments.url(for: $0) }, + at: index + ) + } +} + +// MARK: - One row + +/// A compact row: **small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename** +/// (05-card-window.md ▸ Attachments). +/// +/// Middle truncation rather than tail, because a filename's tail is its extension and a sidebar +/// 26 characters wide would otherwise turn every screenshot into `"Screen Shot 2026-07-2…"` — the +/// one part of the name that says what the file *is* is the part that would go. +private struct AttachmentRow: View { + + let name: String + let url: URL? + let isSelected: Bool + let isSectionFocused: Bool + let thumbnails: AttachmentThumbnailCache + let pointSize: CGFloat + let displayScale: CGFloat + + private var side: CGFloat { CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize) } + private var padding: CGFloat { CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize) } + + private var slot: AttachmentThumbnailKey.Slot? { + url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) } + } + + var body: some View { + HStack(spacing: padding) { + thumbnail + .frame(width: side, height: side) + Text(name) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .padding(.horizontal, padding) + .padding(.vertical, padding / 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selectionFill, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + .foregroundStyle(isSelected && isSectionFocused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .help(name) + .accessibilityElement(children: .combine) + .accessibilityLabel(name) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .task(id: url?.path) { + guard let slot, let url else { return } + await thumbnails.load(slot, url: url, scale: displayScale) + } + } + + /// The selection fill follows **focus**, the standard macOS list treatment: the accent colour + /// while the section has the keyboard, a quiet grey when it does not, so a selected row never + /// claims to be the thing the arrow keys are about to move. + private var selectionFill: AnyShapeStyle { + guard isSelected else { return AnyShapeStyle(.clear) } + return isSectionFocused ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary) + } + + /// The generated thumbnail once there is one, the file's Finder icon until then — and forever, + /// for anything QuickLook declines (05: "small QuickLook thumbnail (Finder-icon fallback)"). + @ViewBuilder + private var thumbnail: some View { + if let slot, let image = thumbnails.thumbnail(for: slot) { + Image(decorative: image, scale: displayScale) + .resizable() + .aspectRatio(contentMode: .fit) + } else if let url { + Image(nsImage: thumbnails.icon(forFileAt: url)) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "doc") + .foregroundStyle(.secondary) + } + } +} + +// MARK: - QuickLook + +/// The Space key's panel — `QLPreviewPanel`, the system's own, shared across every card window. +/// +/// **One shared presenter, because there is one shared panel**: `QLPreviewPanel.shared()` is a +/// process-wide singleton, so a per-window data source would be a set of objects racing to be the +/// one it points at. Space in a second card window simply re-points the panel at that window's +/// files, which is also what Finder does across two windows. +/// +/// The panel is driven by setting its data source directly rather than through the responder +/// chain's `acceptsPreviewPanelControl(_:)` dance: this app's key view at that moment is a SwiftUI +/// focusable, which is not an `NSResponder` we own, and the direct route is the one that does not +/// depend on where SwiftUI happens to put its hosting views. +@MainActor +final class AttachmentQuickLook: NSObject, QLPreviewPanelDataSource { + + static let shared = AttachmentQuickLook() + + private var items: [URL] = [] + + /// Space **toggles**, Finder's own behaviour: pressing it again on the row already showing puts + /// the panel away rather than re-opening it. + func toggle(urls: [URL], at index: Int) { + guard let panel = QLPreviewPanel.shared() else { return } + guard !urls.isEmpty, urls.indices.contains(index) else { return } + + if panel.isVisible, items == urls, panel.currentPreviewItemIndex == index { + panel.orderOut(nil) + return + } + + items = urls + panel.dataSource = self + panel.reloadData() + panel.currentPreviewItemIndex = index + panel.makeKeyAndOrderFront(nil) + } + + nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + MainActor.assumeIsolated { items.count } + } + + /// The bridge to `NSURL` happens **outside** the isolation hop on purpose: `any QLPreviewItem` + /// is not `Sendable`, so it may not be the thing `assumeIsolated` returns; `URL` is, so the + /// value that crosses is the plain one and the Objective-C cast is done here. + nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + let url: URL? = MainActor.assumeIsolated { + items.indices.contains(index) ? items[index] : nil + } + guard let url else { return nil } + return url as NSURL + } +} diff --git a/Kanban/UI/Card/CardWindowDrop.swift b/Kanban/UI/Card/CardWindowDrop.swift new file mode 100644 index 0000000..0217741 --- /dev/null +++ b/Kanban/UI/Card/CardWindowDrop.swift @@ -0,0 +1,130 @@ +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - The payload split + +/// **Drop precedence is split by payload** (05-card-window.md ▸ Attachments, settled): "file drops +/// import as attachments anywhere in the window — Edit mode included, the text editor never +/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as +/// ordinary insertion, and is inert elsewhere in the window." +/// +/// ### The split is enforced from both ends, and neither end knows about the other +/// +/// - **The editor declines files.** `CardBodyTextView.acceptableDragTypes` filters `public.file-url` +/// (and the Carbon-era `NSFilenamesPboardType` AppKit still puts beside it) out of what the text +/// view registers for, so AppKit's drag hit-test walks *past* the text view to the window-level +/// drop target above it. That is the whole of "the text editor never intercepts a file drop" — a +/// deregistration, not a handler that re-dispatches. +/// - **The window declines text.** The delegate below is attached with `.onDrop(of: [.fileURL], …)` +/// and its `validateDrop` asks the predicate here, so a text drag never matches it at all and +/// AppKit offers the drag to the text view instead — which takes it as an ordinary insertion at +/// the caret, `NSTextView`'s own behaviour, untouched. Outside the editor's bounds nothing accepts +/// it, which is 05's "inert everywhere else". +/// +/// Two deregistrations meeting in the middle, rather than one arbiter deciding: there is no third +/// state where both accept, and no ordering between them to get wrong. +enum CardWindowDrop { + + /// Whether a drag's declared types make it a **file** payload — the window's — as opposed to + /// text, which is the editor's. + /// + /// Conformance to `public.file-url`, not equality: a Finder drag registers the concrete type + /// (`public.png`) beside the file URL, and a synthetic drag may register a subtype of it. A URL + /// dragged out of a browser is `public.url`, which does *not* conform to `public.file-url` — so + /// it stays the editor's, exactly as the rule says a dragged link should. + nonisolated static func isFilePayload(typeIdentifiers: [String]) -> Bool { + typeIdentifiers.contains { identifier in + UTType(identifier)?.conforms(to: .fileURL) ?? false + } + } + + /// Whether the window will import this drag: **at least one payload that is a file and not a + /// directory.** + /// + /// The folder half is `FinderDrop.isDirectory(typeIdentifiers:)` — the board's own hover read, + /// called rather than re-derived, so "a package is a directory" cannot come to mean two things + /// in one app. A folders-only drag answers `false` here and is therefore an incompatible payload + /// at the cursor: no highlight, a refusal cursor, and nothing written. A mixed drag answers + /// `true`, imports its files, and names the folders it skipped in a loss row (`FinderDrop.land`) + /// — the board-side refusal semantics, applied here because the importer they protect is the + /// same one. + nonisolated static func accepts(payloads: [[String]]) -> Bool { + payloads.contains { types in + isFilePayload(typeIdentifiers: types) && !FinderDrop.isDirectory(typeIdentifiers: types) + } + } +} + +// MARK: - The window-wide drop delegate + +/// **The drop surface is the whole window** (05-card-window.md ▸ Attachments) — one delegate over +/// the card window's entire content area, body column and sidebar alike, Edit mode and the +/// raw-source outlet included. +/// +/// It is attached at the top of `CardWindowView`'s body rather than to the attachments section, +/// which is what the design asks for and what makes the rule cheap: there is exactly one drop region +/// in this window, so there is no single-target-dispatch problem to solve here at all (contrast the +/// board, where lane, card and strip regions overlap — `BoardDrops`). The text view is *inside* this +/// region and simply does not accept the file types, so the drag falls through to it. +/// +/// The write is `BoardStore.importAttachments(_:toCard:)`, reached through `FinderDrop.land` — the +/// same store method, the same Finder-style collision rename, the same banners as the board window's +/// drop onto a card face. Nothing about importing an attachment is re-implemented here; only *which +/// card* is decided differently, and in a card window that is not a decision at all. +struct CardWindowDropDelegate: DropDelegate { + + let store: BoardStore + let cardID: ItemID + + /// **The mutating-gesture rule, applied to the one gesture that arrives from outside the app** + /// (`BoardDropContext.acceptsFileDrops`): under the read-only lock a file drop refuses at the + /// window, with no proposal — the board's own posture, and the same predicate. The board's + /// second clause (an inline title editor focused) has no card-window counterpart: this window's + /// editors are the body and the raw-source outlet, and 05 puts a file drop *through* both of + /// them on purpose. + private var acceptsFileDrops: Bool { + !store.isReadOnly + } + + func validateDrop(info: DropInfo) -> Bool { + guard acceptsFileDrops else { return false } + return CardWindowDrop.accepts( + payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers) + ) + } + + /// Always `.copy` for a payload we will take — importing a file leaves the original where it + /// was, which is what the badge should say — and `.cancel` for one we will not. + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: validateDrop(info: info) ? .copy : .cancel) + } + + /// Commits the drop. `BoardDropContext.commitFileDrop`'s shape, minus the board's hover state: + /// a provider's file URL loads asynchronously (it is never synchronous for a Finder drag), the + /// store call happens back on the main actor, and the resolved URLs — not the declared types — + /// are the authority on what is a folder. + func performDrop(info: DropInfo) -> Bool { + guard acceptsFileDrops else { return false } + let providers = info.itemProviders(for: [.fileURL]) + guard !providers.isEmpty else { return false } + + let store = store + let cardID = cardID + Task { @MainActor in + var urls: [URL] = [] + for provider in providers { + if let url = await FileDropLoading.url(from: provider) { urls.append(url) } + } + guard !urls.isEmpty else { return } + + // The sandbox's half: a Finder drag hands the app an extension for what it dropped, and + // the copy is the read that needs it. `start…` answers false for a URL that carries no + // scope of its own, so only the ones that opened are closed again. + let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } + defer { for url in scoped { url.stopAccessingSecurityScopedResource() } } + + FinderDrop.land(urls, landing: .attach(cardID: cardID), into: store) + } + return true + } +} diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift index e9231e6..b56e78d 100644 --- a/Kanban/UI/Card/CardWindowMetrics.swift +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -79,6 +79,27 @@ enum CardWindowMetrics { columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize) } + // MARK: - The attachments section + + /// An attachment row's thumbnail: a **small** square, one and a half ems on a side + /// (05-card-window.md ▸ Attachments: "Compact rows: small QuickLook thumbnail (Finder-icon + /// fallback) + middle-truncated filename, one row per file"). + /// + /// Derived rather than a point size, like everything else here, and deliberately *small*: this + /// is an inventory, not a gallery — "viewing media is the card window's job" was settled about + /// the window, not about this list, and a row tall enough to see a screenshot in would push the + /// four sections beneath it off the sidebar. + static func attachmentThumbnailSide(bodyPointSize: CGFloat) -> CGFloat { + (bodyPointSize * 1.5).rounded() + } + + /// The inset inside an attachment row, and the gap between its thumbnail and its filename — + /// half a gutter, the rendered body's rhythm, so the sidebar's list and the body's blocks are + /// spaced by the same unit. + static func attachmentRowPadding(bodyPointSize: CGFloat) -> CGFloat { + previewPadding(bodyPointSize: bodyPointSize) + } + // MARK: - The rendered body /// One step of structural indent in Preview — a list level, a quote level. One and a half ems, diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index 439a388..dcae306 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers // MARK: - CardWindowView @@ -52,6 +53,14 @@ struct CardWindowView: View { let rawSource: CardRawSourceSession /// Whether a checkbox may write — `false` under the board's read-only lock. let isEditable: Bool + /// This window's attachments section: the listing, the selection, and the two writes it starts + /// (05 ▸ Attachments). + let attachments: CardAttachments + /// This window's thumbnail memory, held by the host so it outlives a snapshot. + let thumbnails: AttachmentThumbnailCache + /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole + /// window**"). `nil` only where a caller has no store to import through. + let fileDrop: CardWindowDropDelegate? /// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw. let onToggleTask: (Int, Bool) -> Void @@ -74,6 +83,18 @@ struct CardWindowView: View { /// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed /// to disk on the way in regardless. var body: some View { + content + // **The drop surface is the whole window** (05 ▸ Attachments), which is why it hangs + // here — outside the raw-source swap, so a file dropped while the source outlet is open + // still imports — rather than on the attachments section it fills. The body editor lets + // file drags through to this by declining the file types + // (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and + // so is never offered here at all, which is the other half of the payload split. + .modifier(WindowFileDrop(delegate: fileDrop)) + } + + @ViewBuilder + private var content: some View { if rawSource.isActive { CardRawSourceView(session: rawSource, presentation: bodyPresentation) } else { @@ -183,9 +204,7 @@ struct CardWindowView: View { private var sidebar: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { - // m6-card-attachments: every top-level file of `attachments/`, compact rows with a - // QuickLook thumbnail, keyboard-navigable. - section("Attachments") + CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) // m6-card-sidebar: the embedded style editor — the same component the board popover // and Style… already host (`StyleEditor`). section("Style") @@ -204,16 +223,33 @@ struct CardWindowView: View { } /// A stacked small-caps header over the space its section will occupy (05: "Stacked sections - /// under small-caps headers"). + /// under small-caps headers") — the same header the Attachments section fills in for real, so + /// the four still-empty ones cannot drift from it. private func section(_ title: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption.weight(.semibold)) - .textCase(.uppercase) - .foregroundStyle(.secondary) - Divider() + CardSidebarSectionHeader(title: title) + .accessibilityElement(children: .combine) + } +} + +// MARK: - The window-wide drop + +/// Attaches the whole-window file drop, or nothing at all. +/// +/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same* +/// view identity in both cases: a window whose store arrives a turn after its view would otherwise +/// re-mount its entire content when the drop target appeared, throwing away the body's scroll +/// position for nothing. +private struct WindowFileDrop: ViewModifier { + + let delegate: CardWindowDropDelegate? + + func body(content: Content) -> some View { + if let delegate { + // `.fileURL` alone: a text drag never matches, so it is never offered here and falls to + // the Edit editor, where `NSTextView` inserts it at the caret (05 ▸ Attachments). + content.onDrop(of: [.fileURL], delegate: delegate) + } else { + content } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) } } diff --git a/Kanban/UI/FinderDrop.swift b/Kanban/UI/FinderDrop.swift new file mode 100644 index 0000000..1e0d465 --- /dev/null +++ b/Kanban/UI/FinderDrop.swift @@ -0,0 +1,186 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +// This file is a **pure extraction** from `BoardDrops.swift`, moved verbatim the moment a second +// window needed it: the card window's whole-window attachment drop (05-card-window.md ▸ Attachments) +// runs the same two rules this file holds — how a dropped `NSItemProvider` becomes a file URL, and +// which half of a Finder drag is a file rather than a folder. Both windows import through +// `BoardStore.importAttachments`, so a second copy of either rule would be a second folder-refusal +// semantics able to disagree with the board's about what a package is. +// +// Nothing here knows about a board's geometry, its lanes, or its drag session — which is what made +// the move mechanical. `FileDropTarget.Landing`, the one board-shaped type `land` still takes, stays +// in `BoardDrops.swift`: the card window passes `.attach(cardID:)` and the board resolves its own. + +// MARK: - Loading what Finder dropped + +/// The one place an `NSItemProvider` from an external drag is unwrapped into a file URL. +enum FileDropLoading { + + /// The file URL a dropped provider carries, or `nil` when it carries none. + /// + /// **`loadItem` on `.fileURL`, not `loadInPlaceFileRepresentation`.** A `public.file-url` item is + /// what Finder actually puts on the dragging pasteboard, and the drag itself is what grants the + /// sandbox the extension to read it — the caller opens the scope and copies. The in-place + /// representation would hand back a URL valid only for the duration of its own completion block, + /// forcing the copy to happen off the main actor inside a callback, for no benefit here. + /// + /// The completion fires on an arbitrary queue — never assume the main actor — so this is a plain + /// continuation wrapper; a `CheckedContinuation` resumes from any queue whatever isolation the + /// call site started from. Both shapes a `.fileURL` item arrives in are accepted: the `Data` + /// encoding it normally takes, and a bare `URL`. + /// + /// `@MainActor` for a concurrency reason rather than a behavioural one: the provider comes off a + /// `DropInfo` on the main actor and is not `Sendable`, so a nonisolated entry point would be + /// *sending* it across domains. Staying on the actor it came from keeps the hand-off to the + /// completion handler — which fires wherever AppKit likes — the only crossing there is. + @MainActor + static func url(from provider: NSItemProvider) async -> URL? { + await withCheckedContinuation { continuation in + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { + continuation.resume(returning: url) + } else if let url = item as? URL { + continuation.resume(returning: url) + } else { + continuation.resume(returning: nil) + } + } + } + } +} + +// MARK: - Files in, folders out + +/// What a Finder drag is carrying and where it lands — **the folder refusal, both halves** +/// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover … the attachment model is flat +/// top-level files, and the importer refuses directories by design"). +/// +/// ### Why the payload is read twice +/// +/// The rule is a *hover* rule, and hover has only the providers' declared types to go on: a file URL +/// is not resolved until the drop, and resolving one during hover is neither offered nor affordable. +/// So the drag is read twice, and the two reads have different jobs: +/// +/// - **At hover, from `registeredTypeIdentifiers`** — Finder registers the concrete UTI beside +/// `public.file-url`, so a folder announces itself as `public.folder` before anything is loaded. +/// That is what makes a folders-only drag refuse *at the cursor*: no highlight, no shadows, the +/// incompatible-payload read (`BoardDropContext.acceptsFileDrop`). +/// - **At the drop, from the URLs themselves** — `FinderDrop.partition`, which is a filesystem fact +/// rather than a declaration and therefore the authority. It is what actually decides what gets +/// written. +/// +/// **Unknown at hover is treated as a file**, deliberately: a provider that registers only +/// `public.file-url` and no concrete type — a synthetic drag, or an unusual source — cannot be +/// classified until its URL resolves, and the optimistic read means such a drag still engages, still +/// shows its shadows, and is sorted out authoritatively at the drop. The pessimistic read would make +/// an ordinary file drag silently dead, which is the far worse failure. +/// +/// **A package is a directory.** Conformance to `public.directory` — not equality with +/// `public.folder` — is the test, so a `.app`, an `.rtfd`, or any other bundle is refused exactly as +/// a plain folder is: the flat top-level attachment model has no more room for one than for the +/// other, and `isDirectory(at:)` says the same thing at the drop. +enum FinderDrop { + + // MARK: The hover read — declared types + + /// Whether a provider's registered types describe a directory (folders and packages alike). + /// + /// An identifier the system does not know, and an empty list, are *not* directories: this is the + /// optimistic side of the unknown-at-hover rule above. + nonisolated static func isDirectory(typeIdentifiers: [String]) -> Bool { + typeIdentifiers.contains { identifier in + UTType(identifier)?.conforms(to: .directory) ?? false + } + } + + /// How many of these providers are importable — the file count every hover-time proposal is + /// sized by, and `0` is the refusal that keeps a folders-only drag from ever engaging. + /// + /// `@MainActor` for the same reason `FileDropLoading.url(from:)` is: an `NSItemProvider` off a + /// `DropInfo` is not `Sendable`, so it stays on the actor it arrived on. + @MainActor + static func importableCount(_ providers: [NSItemProvider]) -> Int { + providers.filter { !isDirectory(typeIdentifiers: $0.registeredTypeIdentifiers) }.count + } + + /// How many shadows the create path draws — **one nominal-height shadow per incoming file** + /// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), **floored + /// at one**: "when macOS withholds item counts during hover the count floors at one shadow, the + /// commit unaffected". + /// + /// The floor is a *hover* concession and nothing more. A drag whose providers cannot be counted + /// still opens a slot the user can aim at, and the write is `FinderDrop.land`'s — resolved URLs, + /// partitioned against the filesystem — so one shadow standing in for three files costs the drop + /// nothing. It is read from the drag on every sample rather than captured at `dropEntered`: a + /// delegate can be entered without this window ever having seen the enter callback (single-target + /// dispatch hands the session to whichever region is deepest), and a count that was never set + /// would draw the wrong number of shadows. + @MainActor + static func shadowCount(_ providers: [NSItemProvider]) -> Int { + max(1, importableCount(providers)) + } + + // MARK: The drop read — the filesystem + + /// Whether `url` is a directory, as the filesystem answers it — the authoritative read. + /// + /// `resourceValues` first (the real answer, packages included), `fileExists` as the fallback for + /// a URL whose resource values cannot be read, and the purely lexical `hasDirectoryPath` last, + /// for a source that has already vanished between the drag and the drop. + nonisolated static func isDirectory(at url: URL) -> Bool { + if let flag = try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory { return flag } + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) { + return isDirectory.boolValue + } + return url.hasDirectoryPath + } + + /// Splits a dropped set into what will be written and what will be named as skipped, preserving + /// input order in both halves — the create path mints its cards in drop order, and the order the + /// user dropped in is the only order there is. + nonisolated static func partition(_ urls: [URL]) -> (files: [URL], folders: [URL]) { + var files: [URL] = [] + var folders: [URL] = [] + for url in urls { + if isDirectory(at: url) { folders.append(url) } else { files.append(url) } + } + return (files, folders) + } + + // MARK: The write + + /// The drop's **write half**: the files land where the highlight or the shadows showed, and the + /// folders are named in a loss row rather than attempted. + /// + /// **No card is ever minted for an import that cannot succeed** (04-interactions.md): the folders + /// are gone before `createCards` sees the list, so the create path only ever fires with files — + /// "the mint-fail-remove dance is gone" for this reason, not because the store stopped doing it. + /// `BoardStore.createCards` still removes a card whose import failed for a *genuine* reason (an + /// unreadable source, a full disk), and `BoardWriter.importAttachments` still refuses a directory + /// outright: that throw stays as the model layer's backstop for every other caller, and folders + /// simply never reach it from here. + /// + /// **Zero files is a valid arrival, and writes nothing.** The hover refusal means a folders-only + /// drag normally never gets here at all; a payload whose types were unknown at hover can, and the + /// honest answer is the loss row alone — no write, no empty card, nothing to undo. + /// + /// A **loss row, not a failure one-shot** (02-architecture.md § the banner vocabulary): nothing + /// failed here. The files the user dropped arrived; the folders were never things this app could + /// take, and `postSkippedFolders` is silent at zero, so an all-files drop says nothing at all. + @MainActor + static func land(_ urls: [URL], landing: FileDropTarget.Landing, into store: BoardStore) { + let (files, folders) = partition(urls) + if !files.isEmpty { + switch landing { + case let .attach(cardID): + store.importAttachments(files, toCard: cardID) + case let .create(laneID, index): + store.createCards(fromFiles: files, inLane: laneID, at: index) + } + } + store.banners.postSkippedFolders(count: folders.count) + } +} diff --git a/KanbanTests/CardAttachmentsTests.swift b/KanbanTests/CardAttachmentsTests.swift new file mode 100644 index 0000000..655c984 --- /dev/null +++ b/KanbanTests/CardAttachmentsTests.swift @@ -0,0 +1,452 @@ +import AppKit +import Foundation +import Testing +import UniformTypeIdentifiers +@testable import Kanban + +/// The card window's **Attachments** section (05-card-window.md ▸ Attachments) — everything about it +/// that is not a pixel. +/// +/// Four things are covered here and the split is deliberate: +/// +/// - **The remove write**, end to end: a file leaves `attachments/` and arrives in the *system* +/// Trash, the store's version rides the write bracket, and a failure banners with the Finder +/// phrasing the naming constraint reserves for exactly this operation. +/// - **Add Attachment's target resolution** — the seam `CardWindowHost` fills in, driven as the real +/// wiring rather than a re-typed copy of it, exactly as `RawSourceTests` drives +/// `configureRawSource`. +/// - **The payload split**, as the pure predicate it is: file drags are the window's, text drags are +/// the editor's, folders refuse. +/// - **The section's pure rules** — arrow movement, what a selection does when the listing changes +/// under it, and what Reveal in Finder points at. +/// +/// What is deliberately *not* here: the import path itself (already covered board-side — +/// `FileDropWriteTests`, including the Finder-style collision rename this section reuses verbatim), +/// the listing (already covered loader-side — `BoardLoaderTests` ▸ Card attachments, and the section +/// reads that very field rather than a seam of its own), and the view layer's keyboard and QuickLook, +/// which need a window. + +// MARK: - Fixtures + +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) + +private func cardFolder(_ fixture: WriterFixture, _ card: String = Ident.card1) -> URL { + fixture.url("\(Ident.lane1)/\(card)") +} + +/// A name nothing else in the user's Trash can already be called. +/// +/// Every test below trashes into the **real** `~/.Trash`: `trashItem` has nowhere else to put a file +/// that lives in a temp directory on the boot volume, and faking it would be faking the one thing +/// these tests exist to prove. Unique names mean the tidy-up can only ever remove this run's own +/// residue, and a Trash that already held a `shot.png` is never touched. +private func uniqueName(_ stem: String, _ ext: String) -> String { + "\(stem)-\(UUID().uuidString).\(ext)" +} + +/// Takes a trashed file back out of `~/.Trash`, and answers whether it was there — **the assertion +/// and the tidy-up in one call**, because they want the same lookup: a file that can be purged is a +/// file that reached the Trash rather than being hard-deleted. +@discardableResult +private func purgeFromTrash(_ url: URL?) -> Bool { + guard let url, FileManager.default.fileExists(atPath: url.path) else { return false } + try? FileManager.default.removeItem(at: url) + return true +} + +/// The same, by name, for the paths that do not hand the resulting URL back (the store's). +@discardableResult +private func purgeFromTrash(named name: String) -> Bool { + guard let trash = try? FileManager.default.url( + for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false + ) else { return false } + return purgeFromTrash(trash.appendingPathComponent(name)) +} + +// MARK: - The remove write + +@MainActor +@Suite("BoardWriter ▸ removeAttachment(named:fromCard:)") +struct RemoveAttachmentWriterTests { + + /// The promise in one test: the file leaves `attachments/`, its siblings do not, and it is + /// **still on disk** — in the Trash, where the user can put it back without this app's help + /// (05-card-window.md ▸ Attachments: "moves to the **system** Trash, never hard-deletes"). + @Test("The file leaves attachments/ and arrives in the system Trash") + func movesToTheSystemTrash() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let doomed = uniqueName("shot", "png") + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01])) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data([0x02])) + + let trashed = try BoardWriter.removeAttachment(named: doomed, fromCard: cardFolder(fixture)) + + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["notes.txt"]) + #expect(purgeFromTrash(trashed), "moved to the Trash, never hard-deleted") + } + + /// **The listing is the guard**, and this is the whole class of things it refuses in one test: + /// a subfolder, a file inside one, a hidden file, a path, and a name that is simply not there. + /// None of them is a failure — the reload is the authority on what a card has — so nothing is + /// thrown, nothing is written, and the card's own `index.md` is never opened. + @Test("Anything the listing does not show is a silent no-op") + func onlyTopLevelFilesAreRemovable() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let card = cardFolder(fixture) + let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments" + try fixture.file("\(attachments)/keep.png", Data([0x01])) + try fixture.file("\(attachments)/sub/nested.txt", Data([0x02])) + try fixture.file("\(attachments)/.DS_Store", Data([0x03])) + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + for name in ["sub", "sub/nested.txt", ".DS_Store", "gone.png", "", "../index.md"] { + #expect(try BoardWriter.removeAttachment(named: name, fromCard: card) == nil) + } + + #expect(try fixture.entryNames(attachments).sorted() == [".DS_Store", "keep.png", "sub"]) + #expect(try fixture.data("\(attachments)/sub/nested.txt") == Data([0x02])) + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + } + + /// Attachments belong to cards: a lane folder, a board root and a folder that is not there at + /// all are refused before anything is looked at — `importAttachments`' own guards, since this is + /// its inverse. + @Test("A folder that is not a card is refused") + func refusesNonCards() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let atRoot = writeFailure { + _ = try BoardWriter.removeAttachment(named: "shot.png", fromCard: fixture.root) + } + #expect(atRoot?.operation == .removeAttachment(filename: "shot.png")) + + let missing = writeFailure { + _ = try BoardWriter.removeAttachment( + named: "shot.png", + fromCard: fixture.url("\(Ident.lane1)/\(Ident.indexless)-nope") + ) + } + #expect(missing != nil) + } +} + +// MARK: - The store's half + +@MainActor +@Suite("BoardStore ▸ removeAttachment(named:fromCard:)") +struct RemoveAttachmentStoreTests { + + /// **A bracketed write like every other**: it goes out through `performWrite`, so the churn + /// rounds back as one app-mediated reload, and a success says nothing at all. + @Test("Removing rides the write bracket and posts nothing on success") + func removesThroughTheBracket() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let doomed = uniqueName("shot", "png") + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01])) + let store = try BoardStore(rootURL: fixture.root) + + store.removeAttachment(named: doomed, fromCard: card1) + + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty) + #expect(purgeFromTrash(named: doomed), "the store's remove is the Trash's, not a delete") + #expect(store.banners.oneShots.isEmpty) + #expect(store.banners.losses.isEmpty) + } + + /// The vanished-target guard every gesture in the store makes, ancestor-walked: a tombstoned + /// card, a live card under a tombstoned lane, a card that isn't there, a *lane* id, and an empty + /// name all write nothing and say nothing. + @Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing") + func inertTargets() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card2)", """ + --- + schema: 1 + title: Second + order: 2048 + created: 2026-01-01T09:00:00Z + deleted: 2026-03-03T09:00:00Z + --- + Second body. + + """) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png", Data([0x01])) + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02])) + let store = try BoardStore(rootURL: fixture.root) + + store.removeAttachment(named: "shot.png", fromCard: card2) // tombstoned + store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card + store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane + store.removeAttachment(named: "", fromCard: card1) // no name + + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["shot.png"]) + #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure") + } + + /// A genuine failure banners, in **Finder's** words — the one operation in this app allowed + /// "move to the Trash", because it is the one that uses the system Trash (03-board-ui.md + /// § Trash's naming constraint). The file stays exactly where it was. + @Test("A remove that cannot happen banners with the system-Trash phrasing") + func aFailingRemoveBanners() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments" + try fixture.file("\(attachments)/shot.png", Data([0x01])) + let store = try BoardStore(rootURL: fixture.root) + // Unlinking is the *parent* directory's permission, so this is what a folder the user + // cannot write to looks like from here. + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.url(attachments).path) + + store.removeAttachment(named: "shot.png", fromCard: card1) + + #expect(try fixture.entryNames(attachments) == ["shot.png"], "a failed remove leaves the file") + #expect(store.banners.oneShots.count == 1) + #expect(store.banners.oneShots.first?.error.operation == .removeAttachment(filename: "shot.png")) + #expect( + BannerCenter.headline(for: BoardWriteError( + operation: .removeAttachment(filename: "shot.png"), + path: "/x", + reason: .io(message: "permission denied") + )).hasPrefix("Couldn't move 'shot.png' to the Trash") + ) + } +} + +// MARK: - Add Attachment's target resolution + +@MainActor +@Suite("CardWindowHost ▸ configureAttachments") +struct AddAttachmentTargetTests { + + /// **The window's card, and no other** — the whole of the wiring's job. Driven through the real + /// `configureAttachments`, so the test breaks if the two seams are ever crossed or the id is + /// captured from the wrong place. + @Test("Adding imports into the window's own card, through the board's import path") + func importsIntoItsOwnCard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let sources = try WriterFixture() + defer { sources.tearDown() } + let shot = try sources.file("shot.png", Data([0x89, 0x50])) + let store = try BoardStore(rootURL: fixture.root) + + let attachments = CardAttachments() + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + attachments.importFiles?([shot]) + + #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50])) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("Removing takes the file out of the window's own card") + func removesFromItsOwnCard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let doomed = uniqueName("shot", "png") + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01])) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02])) + let store = try BoardStore(rootURL: fixture.root) + + let attachments = CardAttachments() + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + attachments.removeFile?(doomed) + + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty) + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == [doomed]) + purgeFromTrash(named: doomed) + } + + /// The handle's own gates, in front of the seams: under the read-only lock and before the window + /// has a folder, `remove` does nothing at all — 02-architecture.md's every-entry-point predicate, + /// which is also `AddAttachmentCommand`'s validation. + @Test("The lock disables the section's writes, and the menu row with them") + func theLockDisablesTheSectionsWrites() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01])) + let store = try BoardStore(rootURL: fixture.root) + + let attachments = CardAttachments() + CardWindowHost.configureAttachments(attachments, store: store, cardID: card1) + attachments.cardFolder = cardFolder(fixture) + attachments.names = ["shot.png"] + + attachments.isEditable = false + attachments.remove("shot.png") + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"]) + #expect(!AddAttachmentCommand.isEnabled(attachments)) + + attachments.isEditable = true + #expect(AddAttachmentCommand.isEnabled(attachments)) + #expect(!AddAttachmentCommand.isEnabled(nil), "no card window in front, no row") + + // A window with no folder yet has nothing to add *to*. + attachments.cardFolder = nil + #expect(!AddAttachmentCommand.isEnabled(attachments)) + } +} + +// MARK: - The payload split + +@Suite("CardWindowDrop ▸ the text/file payload split") +struct CardWindowDropPayloadTests { + + /// A Finder drag: the file URL beside the concrete type. The window's. + @Test("A file drag is the window's, whatever it is a file of") + func fileDragsAreTheWindows() { + #expect(CardWindowDrop.isFilePayload(typeIdentifiers: ["public.file-url", "public.png"])) + #expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.png"]])) + #expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.plain-text"]])) + } + + /// **Dragged text is never the window's** — it belongs to the Edit editor at the caret, and is + /// inert elsewhere (05-card-window.md ▸ Attachments). A link dragged out of a browser is + /// `public.url`, which does *not* conform to `public.file-url`, so it stays text. + @Test("Text and browser links are not file payloads") + func textIsNeverTheWindows() { + #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.utf8-plain-text"])) + #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.url", "public.utf8-plain-text"])) + #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.rtf"])) + #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: [])) + #expect(!CardWindowDrop.accepts(payloads: [["public.utf8-plain-text"], ["public.url"]])) + } + + /// The board-side folder refusal, reused rather than restated (`FinderDrop.isDirectory`): a + /// folders-only drag never engages, and a package is a folder. + @Test("A folders-only drag refuses; a mixed drag engages for its files") + func foldersRefuse() { + #expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "public.folder"]])) + #expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "com.apple.application-bundle"]])) + #expect(CardWindowDrop.accepts(payloads: [ + ["public.file-url", "public.folder"], + ["public.file-url", "public.png"] + ])) + } + + /// The other half of the split, at the editor's end: **the text view declines the file types**, + /// which is what lets a file drag fall through to the window-level target above it. A property + /// read, so it is checkable without a drag. + @MainActor + @Test("The body editor never registers for file drags, and still takes text") + func theEditorDeclinesFiles() { + let textView = CardBodyTextView() + textView.isRichText = true + textView.isEditable = true + + let types = textView.acceptableDragTypes + #expect(!types.contains(.fileURL)) + #expect(!types.contains(NSPasteboard.PasteboardType("NSFilenamesPboardType"))) + // What the text view *keeps* is the whole other half of the rule, and it keeps it under + // AppKit's own spelling: an editable `NSTextView` registers the legacy `NSStringPboardType` + // (not `public.utf8-plain-text`) and `public.url`, so both a plain-text drag and a link + // dragged out of a browser still land at the caret as ordinary insertions. + #expect(types.contains(NSPasteboard.PasteboardType("NSStringPboardType"))) + #expect(types.contains(.URL), "a dragged link is the editor's, not the window's") + } +} + +// MARK: - The section's pure rules + +@Suite("CardAttachments ▸ selection and reveal") +struct CardAttachmentsRulesTests { + + private let names = ["a.png", "b.png", "c.png"] + + /// ↑/↓ **clamp rather than wrap**: a list is not a carousel. + @Test("Arrows clamp at both ends") + func arrowsClamp() { + #expect(CardAttachments.moved("a.png", by: 1, in: names) == "b.png") + #expect(CardAttachments.moved("c.png", by: 1, in: names) == "c.png") + #expect(CardAttachments.moved("a.png", by: -1, in: names) == "a.png") + #expect(CardAttachments.moved("c.png", by: -1, in: names) == "b.png") + } + + /// With nothing selected an arrow enters the list from the edge it came from, so the section is + /// usable the instant it takes focus. + @Test("An arrow with nothing selected enters from the edge") + func arrowsEnterFromTheEdge() { + #expect(CardAttachments.moved(nil, by: 1, in: names) == "a.png") + #expect(CardAttachments.moved(nil, by: -1, in: names) == "c.png") + #expect(CardAttachments.moved("gone.png", by: 1, in: names) == "a.png") + #expect(CardAttachments.moved("a.png", by: 1, in: []) == nil) + } + + /// After a remove the selection lands on **the row that took its place** — every macOS list's + /// behaviour, and what makes ⌫ ⌫ ⌫ work without reaching for the mouse between presses. + @Test("A removed selection lands on the row that took its place") + func selectionSettlesAfterARemoval() { + #expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "c.png"]) == "c.png") + #expect(CardAttachments.settle("c.png", was: names, is: ["a.png", "b.png"]) == "b.png") + #expect(CardAttachments.settle("b.png", was: names, is: []) == nil) + } + + /// A selection that survived keeps its row — the common case, another window's import landing + /// somewhere else in the list — and a reload never invents one. + @Test("A surviving selection keeps its row, and none is never invented") + func selectionSurvivesUnrelatedChanges() { + #expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "b.png", "c.png", "d.png"]) == "b.png") + #expect(CardAttachments.settle(nil, was: names, is: names) == nil) + #expect(CardAttachments.settle("gone.png", was: [], is: names) == nil) + } + + /// The observable half of the same rule: assigning a new listing settles the selection with it, + /// which is what keeps a snapshot from leaving the keyboard pointing at a file that is gone. + @MainActor + @Test("Republishing the listing settles the selection") + func republishingSettles() { + let attachments = CardAttachments() + attachments.names = names + attachments.selected = "b.png" + + attachments.names = ["a.png", "c.png"] + + #expect(attachments.selected == "c.png") + } + + /// File ▸ Reveal in Finder's card-window scope: the selected attachment when the section has + /// focus, the card's folder otherwise, and nothing at all for a window with no card left. + @Test("Reveal points at the selected attachment only while the section is focused") + func revealScope() { + let folder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true) + let file = folder.appendingPathComponent("attachments/shot.png") + + #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: true) == [file]) + #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: false) == [folder]) + #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: nil, isSectionFocused: true) == [folder]) + #expect(CardAttachments.revealURLs(cardFolder: nil, selectedURL: nil, isSectionFocused: false).isEmpty) + } + + /// A row's URL comes from the *listing*, never from the name alone: a name the section is not + /// showing resolves to nothing, so Open, Reveal and the drag-out all refuse together. + @MainActor + @Test("Only a listed name resolves to a file") + func onlyListedNamesResolve() { + let attachments = CardAttachments() + attachments.cardFolder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true) + attachments.names = ["shot.png"] + + #expect(attachments.url(for: "shot.png")?.lastPathComponent == "shot.png") + #expect(attachments.url(for: "shot.png")?.deletingLastPathComponent().lastPathComponent == "attachments") + #expect(attachments.url(for: "gone.png") == nil) + #expect(attachments.url(for: "sub/nested.txt") == nil) + } +} diff --git a/README.md b/README.md index c4b0fc0..0135318 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ Lanework is in early development. This list tracks what has actually shipped and - **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched. -- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar's sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board. +- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board. + +- **Attachments** — the sidebar's first section is the card's complete file inventory: every top-level file of its `attachments/` in Finder order, body-embedded ones included, as compact rows carrying a small QuickLook thumbnail (the file's Finder icon until one is generated, and for anything QuickLook won't preview) beside a middle-truncated filename. The whole window is the drop surface — drag files anywhere in it, Edit mode and raw source included, and they import as attachments with Finder-style renames on collision, because the text editor deliberately declines file drags while dragged *text* still lands at the caret exactly as it always did. Folders refuse at the cursor and a mixed drag imports its files and says how many folders it skipped. File ▸ Add Attachment… (⇧⌘A) and a quiet plus in the section header are the same act from the menu bar and the pointer, both opening a multi-select panel into the same import path the board's own file drops use. The section is keyboard-native: it takes focus, arrows walk the rows, Space QuickLooks the selected one in the system's own panel, Return opens it in its default app, and ⌫ moves it to the **system** Trash — never a hard delete, and deliberately distinct from the board's own trash, which is why a failure there says "Couldn't move 'shot.png' to the Trash". Rows drag out their file URL, so a file goes to Finder or another app with no export path of its own; a right-click offers Open, Reveal in Finder and Remove; and File ▸ Reveal in Finder points at the selected attachment while the section holds focus, the card's folder otherwise. Every write is an ordinary bracketed one — one reload, one commit, one banner on failure — and the read-only lock disables adding and removing in place. - **The card body — Preview and Edit** — the body is read as a fully rendered Markdown preview and written as raw Markdown, never a WYSIWYG halfway house. Preview lays out headings, emphasis, code, quotes, lists, GFM tables, thematic breaks and images resolved against the card's own folder; HTML shows verbatim as code, remote images never load (Preview does no networking), links open in the browser or the file's default app, and task-list checkboxes are live — clicking one flips exactly that character in the file and touches no other byte. ⌘E toggles View ▸ Edit Body, Return in Preview enters it, Escape leaves it, and a card whose body is empty opens straight into the editor with the cursor ready. Edit is a monospaced editor with lightweight syntax highlighting — headings emphasized, bold and italic styled, code tinted, link targets and structural markers dimmed — that is presentation only: the text stays the raw Markdown character for character, smart quotes and dashes off. It saves ~700 ms after you stop typing, and flushes the moment you leave Edit or close the window, so neither the preview nor the disk ever lags what you typed. ⌘Z is the editor's own undo, scoped to the session; ⌘F is find-in-text over whichever surface is showing. Three write rules keep the file honest: a body nobody touched is never re-serialized (byte-identical on disk, modification date included), an edit typed and then undone is not written, and the app's own save echoing back through the watcher is not written again. If the file changes underneath you while the buffer has unsaved keystrokes, the buffer wins — the board, the preview and every other window take the new version while your text stays exactly where it is, and your save then lands over theirs. A close that cannot save stops and asks: try again, save a copy elsewhere, or discard.