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