Implement Finder file drops

Files from Finder land on the board per 04-interactions.md § Drag & drop:

- Dropped on a card, they copy into its attachments/ (any type, multi-file),
  the face highlighting while hovered; the hovered card is resolved by
  hit-testing the same analytic masonry frames the card zones are built
  from, so attach-beats-create adds no drop region and cannot drift from
  the dispatch.
- Dropped on lane empty space, one card per file — filename minus extension
  as the title (a blank stem omits the key), fresh GUID, rank at the drop
  position through the ordinary insertion machinery, the file attached —
  all in one bracket; a failed import removes the just-minted card, so
  creating-then-abandoning never leaves an empty card behind.
- Every board drop surface now declares .fileURL beside the two board
  types (the single-target-dispatch rule); tombstoned surfaces are inert;
  file sessions ride a distinct session mode with their own watchdog and
  no hysteresis, leaving the board-drag machinery untouched.
- Also: four empty fixture directories pinned with .keep files so git
  preserves them, and a test-only visibility fix in DragSessionTests.

811 unit tests (12 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 21:20:46 -04:00
parent 90cf82d740
commit 8f116934b4
13 changed files with 992 additions and 59 deletions
+111
View File
@@ -1564,6 +1564,117 @@ public final class BoardStore {
}
}
// MARK: - Finder file drops
// The writes an external Finder file drag performs (04-interactions.md Drag and drop, "Files
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
// one card each. The gesture's half which card, which slot is `BoardDropContext`'s; these are
// ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per
// gesture; a vanished or tombstoned destination is a silent no-op, the reload being the
// authority; failures are the banner's).
/// Copies `urls` into `cardID`'s `attachments/` the drop-on-a-card half.
///
/// **Liveness is ancestor-walked** (`liveItem`): a card under a tombstoned lane renders nowhere,
/// so it is as gone as a deleted one, and a drop on a target that vanished under the gesture
/// writes nothing at all. That is also the whole of "Finder file drops on tombstoned cards are
/// inert" (04-interactions.md The trash) on the write side the gesture refuses to propose one
/// in the first place, and this refuses to serve one that slipped through a reload.
///
/// A lane id is refused for the same reason a lane folder is: attachments belong to cards.
/// Multi-file, any type, and a name already taken is renamed Finder-style rather than
/// overwritten all `BoardWriter.importAttachments`', including its failure shape: the first
/// failing file stops the batch and banners naming it, and everything already copied stays.
public func importAttachments(_ urls: [URL], toCard cardID: ItemID) {
guard !urls.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.importAttachments(urls, intoCard: folder)
}
}
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
/// extension and carrying that file as its attachment the drop-on-empty-space half.
///
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
/// exactly as `moveCards` does), then the m2 import machinery for the file. The whole batch is one
/// `performWrite` bracket, so a five-file drop rounds back as one reload and one commit.
///
/// **The title follows the empty-title rules**: a name that trims to nothing a dotfile whose
/// stem is blank, a file called `" .png"` writes no `title` key at all rather than an empty
/// string, since a missing key is the untitled state and `""` would be a real, blank title
/// (01-storage-format.md § Frontmatter).
///
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
/// that cannot be imported a folder rather than a file, an unreadable source which banners
/// naming it; the cards already made keep their files, matching `importAttachments`' own
/// "everything already imported stays landed". The card whose import failed is removed again
/// before the throw: it was minted moments earlier in this same bracket and holds nothing but
/// what this call put there, and "creating-then-abandoning never leaves an empty card behind"
/// (04-interactions.md Grammar) is the rule it would otherwise break.
public func createCards(fromFiles urls: [URL], inLane laneID: ItemID, at index: Int) {
guard !urls.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return }
let rendered = lane.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
}
guard let ranks else { return }
for (url, rank) in zip(urls, ranks) {
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
// visible siblings by contract, and the rank rides its same-parent degenerate reorder
// inside this same bracket rather than widening the create's signature.
let id = try BoardWriter.createCard(inLane: laneFolder, title: Self.cardTitle(forFile: url))
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
do throws(BoardWriteError) {
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
_ = try BoardWriter.importAttachments([url], intoCard: folder)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
}
}
/// The title a dropped file's card takes: **the filename without its extension**
/// (04-interactions.md Drag and drop), or `nil` no `title` key when that trims to nothing.
///
/// The split is `URL`'s own, which is also Finder's: an extension-less name keeps all of itself,
/// and a multi-dot name loses only the last component (`archive.tar.gz` `archive.tar`), matching
/// the collision-rename rule the same file's attachment goes through.
nonisolated static func cardTitle(forFile url: URL) -> String? {
let stem = url.deletingPathExtension().lastPathComponent
.trimmingCharacters(in: .whitespacesAndNewlines)
return stem.isEmpty ? nil : stem
}
// MARK: - Within-lane sort
/// The lane and the new card ordering one / press would produce, or `nil` when the press