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
|
// MARK: - Within-lane sort
|
||||||
|
|
||||||
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
|
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
|
||||||
|
|||||||
@@ -227,6 +227,189 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Retargeting — external Finder file sessions
|
||||||
|
|
||||||
|
/// Whether `info` is an **external Finder file** session rather than one of ours.
|
||||||
|
///
|
||||||
|
/// Never ambiguous: our own drags arm `DragSession` synchronously at `.onDrag` time, before any
|
||||||
|
/// drop callback can arrive, so a session that is not active but carries `.fileURL` items came
|
||||||
|
/// from outside the app. (A board drag also carries a plain-text representation and no file URL,
|
||||||
|
/// so the two type sets never overlap.)
|
||||||
|
func isFileSession(_ info: DropInfo) -> Bool {
|
||||||
|
!session.isActive && info.hasItemsConforming(to: [.fileURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this board accepts a file drop at all — **the mutating-gesture rule, applied to the
|
||||||
|
/// one gesture that arrives from outside the app**: under the read-only lock (02-architecture.md
|
||||||
|
/// § The lock's scope) or with an inline title editor focused (04-interactions.md ▸ Grammar's
|
||||||
|
/// focused-editor rule) a file drop refuses at the board, with no highlight and no proposal —
|
||||||
|
/// the same refusal `.onDrag` makes by handing back an empty item provider.
|
||||||
|
var acceptsFileDrops: Bool {
|
||||||
|
!store.isReadOnly && !store.isEditingInline
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many files the session carries — the shadow run's length on the create path.
|
||||||
|
///
|
||||||
|
/// Read from `info` 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.
|
||||||
|
private func fileCount(_ info: DropInfo) -> Int {
|
||||||
|
max(1, info.itemProviders(for: [.fileURL]).count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`,
|
||||||
|
/// resolved against the very same analytic masonry geometry.
|
||||||
|
///
|
||||||
|
/// Two answers, in this order:
|
||||||
|
///
|
||||||
|
/// 1. **A card under the cursor always wins** (04-interactions.md ▸ Drag and drop): attach beats
|
||||||
|
/// create, anywhere on the card's bounds. The bounds are the resting frames
|
||||||
|
/// `MasonryPlacement.frames(heights:)` replays — the same reconstruction the card-slot zones
|
||||||
|
/// are built from, never a measured frame (03-board-ui.md § Motion). While a create shadow is
|
||||||
|
/// open the *drawn* cards sit lower than their resting frames, which is exactly the tradeoff
|
||||||
|
/// every proposal in this app makes: the answer stays a pure function of the cursor and the
|
||||||
|
/// snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||||
|
/// 2. **Otherwise the create slot**, from `DropSlotMath.cardSlot` — "new cards land at the drop
|
||||||
|
/// position using the same card-grid zone math ordinary card drags use". The footprint the
|
||||||
|
/// span cap is measured against is the nominal card height, since the cards being proposed do
|
||||||
|
/// not exist yet to have one; the cap only ever truncates a zone that lies *over* an existing
|
||||||
|
/// card, and that region is case 1's.
|
||||||
|
///
|
||||||
|
/// **Positional landing is filed for design ratification.** 04's bullet says only "dropped on
|
||||||
|
/// lane empty space → creates a card"; that the card lands at the *drop position* rather than at
|
||||||
|
/// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent,
|
||||||
|
/// implemented here and awaiting the design's word.
|
||||||
|
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||||
|
guard acceptsFileDrops, let cursor = globalCursor(),
|
||||||
|
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||||
|
let grid = registry.grids[laneID]
|
||||||
|
else {
|
||||||
|
session.proposeFile(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||||
|
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||||
|
let placement = MasonryPlacement(
|
||||||
|
columnCount: grid.columns,
|
||||||
|
columnWidth: MasonryPlacement.columnWidth(
|
||||||
|
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
|
||||||
|
spacing: grid.spacing,
|
||||||
|
origin: grid.frame.origin
|
||||||
|
)
|
||||||
|
let count = fileCount(info)
|
||||||
|
|
||||||
|
// Closed containment — a cursor sitting exactly on a shared edge still counts, and the first
|
||||||
|
// match wins, so the answer is deterministic however the frames abut.
|
||||||
|
let frames = placement.frames(heights: heights)
|
||||||
|
if let index = frames.firstIndex(where: { frame in
|
||||||
|
cursor.x >= frame.minX && cursor.x <= frame.maxX
|
||||||
|
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
|
||||||
|
}), index < rendered.count {
|
||||||
|
session.proposeFile(FileDropTarget(
|
||||||
|
boardRoot: store.rootURL,
|
||||||
|
landing: .attach(cardID: rendered[index].id),
|
||||||
|
fileCount: count
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = DropSlotMath.cardSlot(
|
||||||
|
cursor: cursor,
|
||||||
|
placement: placement,
|
||||||
|
heights: heights,
|
||||||
|
draggedHeight: LaneDropRegistry.nominalCardHeight,
|
||||||
|
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
|
||||||
|
)
|
||||||
|
guard let slot else { return } // a dead region: hold whatever the create slot already was
|
||||||
|
session.proposeFile(FileDropTarget(
|
||||||
|
boardRoot: store.rootURL,
|
||||||
|
landing: .create(laneID: laneID, index: slot),
|
||||||
|
fileCount: count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
|
||||||
|
///
|
||||||
|
/// Both a safety net for a lane whose own drop region goes dead (DRAG-REORDER.md § Single-target
|
||||||
|
/// dispatch) and the live handler for the strip's own surfaces — and unlike a card session, those
|
||||||
|
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
|
||||||
|
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
|
||||||
|
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
|
||||||
|
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
|
||||||
|
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The
|
||||||
|
/// trash).
|
||||||
|
func retargetFileFromStrip(_ info: DropInfo) {
|
||||||
|
guard acceptsFileDrops, let cursor = stripCursor() else {
|
||||||
|
session.proposeFile(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||||
|
let index = LaneLayoutMath.laneIndex(
|
||||||
|
atX: cursor.x,
|
||||||
|
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||||
|
standard: standard(),
|
||||||
|
gap: gap
|
||||||
|
)
|
||||||
|
guard let index, lanes.indices.contains(index) else {
|
||||||
|
session.proposeFile(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retargetFile(inLane: lanes[index].id, info: info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `dropUpdated` answers for a file session: always `.copy` — importing a file leaves the
|
||||||
|
/// original where it was, which is what the badge should say — and `.cancel` when nothing under
|
||||||
|
/// the cursor will take it.
|
||||||
|
func fileDropProposal() -> DropProposal {
|
||||||
|
session.fileTarget != nil ? DropProposal(operation: .copy) : DropProposal(operation: .cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits a file drop: the files land where the highlight or the shadows showed.
|
||||||
|
///
|
||||||
|
/// **The target is captured and the hover state cleared before the load starts.** A provider's
|
||||||
|
/// file URL loads asynchronously — it is never synchronous for a Finder drag — and a fast second
|
||||||
|
/// drag arriving in the meantime must not find a stale target sitting there. The store call then
|
||||||
|
/// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file
|
||||||
|
/// count (`BoardStore.importAttachments` / `createCards`).
|
||||||
|
func commitFileDrop(_ info: DropInfo) -> Bool {
|
||||||
|
guard acceptsFileDrops,
|
||||||
|
let target = session.fileTarget,
|
||||||
|
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
|
||||||
|
else {
|
||||||
|
session.proposeFile(nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let providers = info.itemProviders(for: [.fileURL])
|
||||||
|
session.proposeFile(nil)
|
||||||
|
guard !providers.isEmpty else { return false }
|
||||||
|
|
||||||
|
let store = self.store
|
||||||
|
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 — an ordinary in-container path — so only the ones that opened are
|
||||||
|
// closed again.
|
||||||
|
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
|
||||||
|
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
|
switch target.landing {
|
||||||
|
case let .attach(cardID):
|
||||||
|
store.importAttachments(urls, toCard: cardID)
|
||||||
|
case let .create(laneID, index):
|
||||||
|
store.createCards(fromFiles: urls, inLane: laneID, at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: The drop proposal the badge tracks
|
// MARK: The drop proposal the badge tracks
|
||||||
|
|
||||||
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
|
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
|
||||||
@@ -326,56 +509,112 @@ 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: - Drop delegates
|
// MARK: - Drop delegates
|
||||||
|
|
||||||
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
|
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
|
||||||
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
|
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
|
||||||
// even when that target's declared content types don't match the session's payload. So every
|
// even when that target's declared content types don't match the session's payload. So every
|
||||||
// delegate below accepts **both** board types and routes internally; a region whose topmost target
|
// delegate below accepts **all three** session types — cards, lanes, and external Finder file drags
|
||||||
// understood only one of them would be a dead zone for the other — no hover callbacks, and a release
|
// — and routes internally; a region whose topmost target understood only some of them would be a
|
||||||
// there would snap back instead of committing.
|
// dead zone for the rest — no hover callbacks, and a release there would snap back instead of
|
||||||
|
// committing.
|
||||||
//
|
//
|
||||||
// m5-finder-drops: the next card adds external Finder file sessions (`.fileURL`) to this same
|
// **The file routing is resolved inside these delegates, not by a drop target of the card's own.**
|
||||||
// dispatch — files onto a card become attachments, files onto lane empty space become cards
|
// A per-face `onDrop` would be the deepest region under the cursor and would therefore have to
|
||||||
// (04-interactions.md ▸ Drag and drop) — and the same dead-region rule applies to them, so the type
|
// re-implement card and lane routing too, just to avoid becoming a dead zone for them — a second
|
||||||
// list and the routing switch in each delegate below grow by one case rather than gaining a delegate
|
// copy of the dispatch, able to disagree with this one. Instead a card under the cursor is found by
|
||||||
// of their own.
|
// hit-testing the *analytic* masonry frames the card zones are already built from
|
||||||
|
// (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the
|
||||||
|
// geometry the shadows use.
|
||||||
|
|
||||||
/// The board types every delegate declares. Spelled once so no target can accidentally accept fewer.
|
/// The board's own drag types. Spelled once so no target can accidentally accept fewer.
|
||||||
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
|
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
|
||||||
|
|
||||||
|
/// Every type a board surface's `onDrop` declares: our own two, plus the external Finder file drag
|
||||||
|
/// (04-interactions.md ▸ Drag and drop). **A target that left `.fileURL` off would be a dead zone
|
||||||
|
/// that strands a file session** — no hover callbacks, and a refused release with nothing to explain
|
||||||
|
/// why (DRAG-REORDER.md § Single-target dispatch).
|
||||||
|
let boardDropTypes: [UTType] = boardDragTypes + [.fileURL]
|
||||||
|
|
||||||
/// One lane's drop target, attached to the whole lane body.
|
/// One lane's drop target, attached to the whole lane body.
|
||||||
///
|
///
|
||||||
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
|
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
|
||||||
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
|
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
|
||||||
/// keeps working while the cursor crosses lane bodies.
|
/// keeps working while the cursor crosses lane bodies. **File sessions** resolve against those same
|
||||||
|
/// masonry zones — onto a card they become attachments, onto empty space one card per file.
|
||||||
struct LaneDropDelegate: DropDelegate {
|
struct LaneDropDelegate: DropDelegate {
|
||||||
|
|
||||||
// m5-finder-drops: a file session resolves against these same masonry zones — onto a card it
|
|
||||||
// becomes attachments, onto empty space a card per file (04-interactions.md ▸ Drag and drop).
|
|
||||||
|
|
||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
let laneID: ItemID
|
let laneID: ItemID
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
func validateDrop(info: DropInfo) -> Bool {
|
||||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
if context.isFileSession(info) { return context.acceptsFileDrops }
|
||||||
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dropEntered(info: DropInfo) { retarget() }
|
func dropEntered(info: DropInfo) { retarget(info) }
|
||||||
|
|
||||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||||
retarget()
|
retarget(info)
|
||||||
return context.dropProposal()
|
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// No `dropExited`, deliberately: the proposal is meant to **hold** while the cursor leaves for
|
/// **Only file sessions have an exit hook.** A card or lane proposal is meant to *hold* while the
|
||||||
// ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md § Hysteresis).
|
/// cursor leaves for ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md §
|
||||||
|
/// Hysteresis) — while a file proposal is only ever "what is under the cursor now", so leaving
|
||||||
|
/// the lane body clears the highlight.
|
||||||
|
func dropExited(info: DropInfo) {
|
||||||
|
guard context.isFileSession(info) else { return }
|
||||||
|
context.session.proposeFile(nil)
|
||||||
|
}
|
||||||
|
|
||||||
func performDrop(info: DropInfo) -> Bool {
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
context.commitDrop()
|
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func retarget() {
|
private func retarget(_ info: DropInfo) {
|
||||||
|
if context.isFileSession(info) {
|
||||||
|
context.retargetFile(inLane: laneID, info: info)
|
||||||
|
return
|
||||||
|
}
|
||||||
context.revalidateProposal()
|
context.revalidateProposal()
|
||||||
if context.session.isDraggingLanes {
|
if context.session.isDraggingLanes {
|
||||||
context.retargetLanes()
|
context.retargetLanes()
|
||||||
@@ -392,26 +631,41 @@ struct LaneDropDelegate: DropDelegate {
|
|||||||
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
|
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
|
||||||
/// same shared function the lane delegates use, resolving the lane under the cursor analytically —
|
/// same shared function the lane delegates use, resolving the lane under the cursor analytically —
|
||||||
/// the safety net for a lane whose own drop region goes dead.
|
/// the safety net for a lane whose own drop region goes dead.
|
||||||
|
/// File sessions are the strip's twin safety net, and here its own surfaces are a *live* handler for
|
||||||
|
/// them rather than only a fallback: a file drag has no proposal to hold, so hovering a gap, the
|
||||||
|
/// outer margin, or the trash column clears the target outright.
|
||||||
struct StripDropDelegate: DropDelegate {
|
struct StripDropDelegate: DropDelegate {
|
||||||
|
|
||||||
// m5-finder-drops: the strip is a file session's safety net too — the same dead-region
|
|
||||||
// hit-testing bug can strand one, and without file support here it would have nowhere to land.
|
|
||||||
|
|
||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
func validateDrop(info: DropInfo) -> Bool {
|
||||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
if context.isFileSession(info) { return context.acceptsFileDrops }
|
||||||
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dropEntered(info: DropInfo) { context.retargetFromStrip() }
|
func dropEntered(info: DropInfo) { retarget(info) }
|
||||||
|
|
||||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||||
context.retargetFromStrip()
|
retarget(info)
|
||||||
return context.dropProposal()
|
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions.
|
||||||
|
func dropExited(info: DropInfo) {
|
||||||
|
guard context.isFileSession(info) else { return }
|
||||||
|
context.session.proposeFile(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func performDrop(info: DropInfo) -> Bool {
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
context.commitDrop()
|
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retarget(_ info: DropInfo) {
|
||||||
|
if context.isFileSession(info) {
|
||||||
|
context.retargetFileFromStrip(info)
|
||||||
|
} else {
|
||||||
|
context.retargetFromStrip()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,22 +673,30 @@ struct StripDropDelegate: DropDelegate {
|
|||||||
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
|
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
|
||||||
/// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the
|
/// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the
|
||||||
/// shadows already show, which is what the shadows promise.
|
/// shadows already show, which is what the shadows promise.
|
||||||
|
/// A file session released over uncovered window chrome commits whatever the file target currently
|
||||||
|
/// names, exactly as this commits the current card/lane proposal — and with no target standing it
|
||||||
|
/// simply refuses, which is the honest answer for a release over nothing.
|
||||||
struct BoardFallbackDropDelegate: DropDelegate {
|
struct BoardFallbackDropDelegate: DropDelegate {
|
||||||
|
|
||||||
// m5-finder-drops: a file session released over uncovered window chrome commits whatever the
|
|
||||||
// file target currently names, exactly as this commits the current card/lane proposal.
|
|
||||||
|
|
||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
|
|
||||||
func validateDrop(info: DropInfo) -> Bool {
|
func validateDrop(info: DropInfo) -> Bool {
|
||||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
if context.isFileSession(info) { return context.acceptsFileDrops }
|
||||||
|
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||||
context.dropProposal()
|
context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The drag left the window's last covering region — with nothing below this one to take over,
|
||||||
|
/// a standing file highlight would be stale.
|
||||||
|
func dropExited(info: DropInfo) {
|
||||||
|
guard context.isFileSession(info) else { return }
|
||||||
|
context.session.proposeFile(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func performDrop(info: DropInfo) -> Bool {
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
context.commitDrop()
|
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,9 +125,10 @@ struct BoardView: View {
|
|||||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||||||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||||||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||||||
// trash) and so simply falls through to here. It accepts *both* board types and routes
|
// trash) and so simply falls through to here. It accepts *every* session type — ours and
|
||||||
// internally, because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
// external Finder file drags alike — and routes internally, because single-target
|
||||||
.onDrop(of: boardDragTypes, delegate: StripDropDelegate(context: dropContext))
|
// dispatch has no fall-through (DRAG-REORDER.md).
|
||||||
|
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
|
||||||
}
|
}
|
||||||
.background(boardBackground)
|
.background(boardBackground)
|
||||||
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
||||||
@@ -135,7 +136,7 @@ struct BoardView: View {
|
|||||||
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
||||||
.background {
|
.background {
|
||||||
Color.clear
|
Color.clear
|
||||||
.onDrop(of: boardDragTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
|
.onDrop(of: boardDropTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
|
||||||
}
|
}
|
||||||
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
|
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
|
||||||
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
|
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
|
||||||
|
|||||||
@@ -19,6 +19,38 @@ struct DropTarget: Equatable, Sendable {
|
|||||||
var index: Int
|
var index: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Where an external file drag would land
|
||||||
|
|
||||||
|
/// Where an external **Finder file** session would land (04-interactions.md ▸ Drag and drop, "Files
|
||||||
|
/// from Finder").
|
||||||
|
///
|
||||||
|
/// A separate type from `DropTarget` because a file session is a separate *mode*: nothing of ours is
|
||||||
|
/// in flight, so there is no dragged run to lift out of the resting layout, no operation to resolve
|
||||||
|
/// against modifiers, and no source board to compare roots with — only a destination and what the
|
||||||
|
/// files would become there.
|
||||||
|
struct FileDropTarget: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The two landings 04 gives a file drop, and the whole of its behavioural split.
|
||||||
|
enum Landing: Equatable, Sendable {
|
||||||
|
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
|
||||||
|
/// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
|
||||||
|
case attach(cardID: ItemID)
|
||||||
|
/// **Onto lane empty space**: one card per file, landing at this position in the lane's
|
||||||
|
/// logical card order.
|
||||||
|
case create(laneID: ItemID, index: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The board under the cursor — only that board's delegates may commit, and only its lanes draw
|
||||||
|
/// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
|
||||||
|
var boardRoot: URL
|
||||||
|
|
||||||
|
var landing: Landing
|
||||||
|
|
||||||
|
/// How many files ride along — the create path's shadow run length, one shadow per card that
|
||||||
|
/// will land. Read off the session's item providers at hover time, floored at one.
|
||||||
|
var fileCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The committed-overlay hold
|
// MARK: - The committed-overlay hold
|
||||||
|
|
||||||
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
|
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
|
||||||
@@ -192,7 +224,21 @@ final class DragSession {
|
|||||||
/// `CommittedHold`.
|
/// `CommittedHold`.
|
||||||
private(set) var hold: CommittedHold?
|
private(set) var hold: CommittedHold?
|
||||||
|
|
||||||
|
// MARK: The external file mode
|
||||||
|
|
||||||
|
/// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
|
||||||
|
/// over nothing that accepts it (`FileDropTarget`).
|
||||||
|
///
|
||||||
|
/// **A distinct mode, deliberately kept out of everything above.** A file session arms none of
|
||||||
|
/// this object's own state — `kind` stays `nil`, so `isActive` stays false, no member is hidden
|
||||||
|
/// from any resting layout, and the marquee and card/lane machinery carry on as if no drag
|
||||||
|
/// existed. It lives here rather than in a drop delegate for the reason the rest does: the
|
||||||
|
/// highlight and the shadows render off it, so it has to be observable and it has to be one
|
||||||
|
/// value the whole window agrees on.
|
||||||
|
private(set) var fileTarget: FileDropTarget?
|
||||||
|
|
||||||
@ObservationIgnored private var watchdog: Task<Void, Never>?
|
@ObservationIgnored private var watchdog: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var fileWatchdog: Task<Void, Never>?
|
||||||
@ObservationIgnored private var holdTimeout: Task<Void, Never>?
|
@ObservationIgnored private var holdTimeout: Task<Void, Never>?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -258,6 +304,49 @@ final class DragSession {
|
|||||||
return members.indices.filter { live.contains(members[$0]) }
|
return members.indices.filter { live.contains(members[$0]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: The file mode's queries and lifecycle
|
||||||
|
|
||||||
|
/// The card an external file drag is hovering **on this board**, or `nil` — the attach
|
||||||
|
/// highlight's one input (`CardFaceView`).
|
||||||
|
func fileAttachTarget(onBoardRooted root: URL) -> ItemID? {
|
||||||
|
guard let fileTarget,
|
||||||
|
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
||||||
|
case let .attach(cardID) = fileTarget.landing
|
||||||
|
else { return nil }
|
||||||
|
return cardID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shadow run a file drop would open in `laneID` on this board — where the created cards
|
||||||
|
/// land and how many there are — or `nil` when the proposal is elsewhere.
|
||||||
|
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
|
||||||
|
guard let fileTarget,
|
||||||
|
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
||||||
|
case let .create(lane, index) = fileTarget.landing,
|
||||||
|
lane == laneID
|
||||||
|
else { return nil }
|
||||||
|
return (index: index, count: max(1, fileTarget.fileCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records where the files would land. `nil` withdraws the proposal — over a gap, the outer
|
||||||
|
/// margin, the trash column, or a board that refuses the drop outright.
|
||||||
|
///
|
||||||
|
/// **No hysteresis, unlike our own sessions.** A card or lane proposal deliberately *holds* while
|
||||||
|
/// the cursor crosses ambiguous territory, because the drop must land where the shadows show even
|
||||||
|
/// if the cursor drifted off a live zone. A file drop has no such contract: it is a plain "what is
|
||||||
|
/// under the cursor right now", so leaving every target simply clears the highlight and the drop
|
||||||
|
/// is refused (the pathfinder's `retargetFile` precedent).
|
||||||
|
func proposeFile(_ target: FileDropTarget?) {
|
||||||
|
guard fileTarget != target else { return }
|
||||||
|
let wasHovering = fileTarget != nil
|
||||||
|
fileTarget = target
|
||||||
|
if target == nil {
|
||||||
|
fileWatchdog?.cancel()
|
||||||
|
fileWatchdog = nil
|
||||||
|
} else if !wasHovering {
|
||||||
|
armFileWatchdog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: Lifecycle
|
||||||
|
|
||||||
/// Begins a card session — live faces or trash rows.
|
/// Begins a card session — live faces or trash rows.
|
||||||
@@ -415,4 +504,28 @@ final class DragSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file mode's own watchdog, and its only guaranteed termination path.
|
||||||
|
///
|
||||||
|
/// An external session is not ours to end: no `performDrop` runs when the user drops the files
|
||||||
|
/// somewhere else entirely, and `onDragSessionUpdated` reports only sessions this app started. A
|
||||||
|
/// drag is a button held down, so the same poll the internal watchdog uses answers here — when
|
||||||
|
/// the button has been up for a grace period and a target is still standing, the highlight is
|
||||||
|
/// stale and goes.
|
||||||
|
private func armFileWatchdog() {
|
||||||
|
fileWatchdog?.cancel()
|
||||||
|
fileWatchdog = Task { @MainActor [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .milliseconds(120))
|
||||||
|
guard let self, self.fileTarget != nil else { return }
|
||||||
|
guard NSEvent.pressedMouseButtons == 0 else { continue }
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
guard !Task.isCancelled, self.fileTarget != nil else { return }
|
||||||
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) {
|
||||||
|
self.proposeFile(nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,13 +98,11 @@ struct LaneView: View {
|
|||||||
.background(selectionBackground)
|
.background(selectionBackground)
|
||||||
.overlay(selectionStroke)
|
.overlay(selectionStroke)
|
||||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||||||
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
|
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
|
||||||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||||||
// the strip's logic — because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
// the strip's logic, external Finder file sessions against those same zones — because
|
||||||
//
|
// single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||||
// m5-finder-drops: external Finder file sessions join this same target and this same
|
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||||
// routing; the type list grows by `.fileURL` and the delegate by one branch.
|
|
||||||
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
@@ -452,11 +450,11 @@ struct LaneView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
|
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
|
||||||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||||||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||||||
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
||||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
|
.animation(Motion.dragReflow(reduced: reduceMotion), value: shadowRun)
|
||||||
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
||||||
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
||||||
// re-derived, so the zones and the drawn grid cannot disagree.
|
// re-derived, so the zones and the drawn grid cannot disagree.
|
||||||
@@ -511,12 +509,34 @@ struct LaneView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere — the
|
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||||||
/// shadow run's position, and the reflow's narrow animation key.
|
|
||||||
private var cardProposal: Int? {
|
private var cardProposal: Int? {
|
||||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
|
||||||
|
/// make-room mechanism, and the reflow's narrow animation key.
|
||||||
|
///
|
||||||
|
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
|
||||||
|
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
|
||||||
|
///
|
||||||
|
/// - **a card drag**, at the dragged cards' frozen heights — the run's real footprint, so the
|
||||||
|
/// drop lands exactly where the shadows are;
|
||||||
|
/// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
|
||||||
|
/// proposed do not exist yet, so there is no measured height to be faithful to.
|
||||||
|
private var shadowRun: ShadowRun? {
|
||||||
|
if let position = cardProposal {
|
||||||
|
return ShadowRun(position: position, heights: drops.session.cardHeights)
|
||||||
|
}
|
||||||
|
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
||||||
|
return ShadowRun(
|
||||||
|
position: proposal.index,
|
||||||
|
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
||||||
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
||||||
///
|
///
|
||||||
@@ -529,14 +549,15 @@ struct LaneView: View {
|
|||||||
private var slots: [LaneSlot] {
|
private var slots: [LaneSlot] {
|
||||||
var result = renderedCards.map(LaneSlot.card)
|
var result = renderedCards.map(LaneSlot.card)
|
||||||
|
|
||||||
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
|
let run = shadowRun
|
||||||
|
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
|
||||||
var placeholderPosition: Int?
|
var placeholderPosition: Int?
|
||||||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
||||||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||||
?? result.count
|
?? result.count
|
||||||
}
|
}
|
||||||
|
|
||||||
let heights = drops.session.cardHeights
|
let heights = run?.heights ?? []
|
||||||
if let shadowPosition {
|
if let shadowPosition {
|
||||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||||
@@ -604,6 +625,16 @@ struct LaneView: View {
|
|||||||
|
|
||||||
// MARK: - Lane slots
|
// MARK: - Lane slots
|
||||||
|
|
||||||
|
/// The run of shadows a lane opens for whichever session is proposing into it — where it starts and
|
||||||
|
/// what each shadow is worth in height.
|
||||||
|
///
|
||||||
|
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
|
||||||
|
/// so the value moves exactly when the proposal does.
|
||||||
|
private struct ShadowRun: Equatable {
|
||||||
|
var position: Int
|
||||||
|
var heights: [CGFloat]
|
||||||
|
}
|
||||||
|
|
||||||
/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
|
/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
|
||||||
///
|
///
|
||||||
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
|
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
|
||||||
@@ -704,9 +735,16 @@ private struct CardFaceView: View {
|
|||||||
.padding(.leading, stripeWidth)
|
.padding(.leading, stripeWidth)
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||||
.overlay(alignment: .leading) { accentStripe }
|
.overlay(alignment: .leading) { accentStripe }
|
||||||
|
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||||||
|
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||||||
|
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||||||
|
// hovered card that is *also* selected still reads as the target.
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
.strokeBorder(
|
||||||
|
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||||
|
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||||||
@@ -961,6 +999,14 @@ private struct CardFaceView: View {
|
|||||||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||||
|
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||||||
|
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||||||
|
/// to one implementation (`BoardDrops`).
|
||||||
|
private var isFileHovered: Bool {
|
||||||
|
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
||||||
|
}
|
||||||
|
|
||||||
private var isRenaming: Bool {
|
private var isRenaming: Bool {
|
||||||
store.transient.renameEditor?.targetID == card.id
|
store.transient.renameEditor?.targetID == card.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import SwiftUI
|
|||||||
/// snapshot's live lanes;
|
/// snapshot's live lanes;
|
||||||
/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸
|
/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸
|
||||||
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
|
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
|
||||||
/// through to the strip's own target, where a cursor over no lane simply holds the proposal;
|
/// through to the strip's own target, where a cursor over no lane simply holds the proposal. That
|
||||||
|
/// covers **Finder file drops** too, which "on tombstoned cards are inert" (▸ The trash): a file
|
||||||
|
/// session over this column resolves to no lane, so no row highlights and a release refuses;
|
||||||
/// - it has **no new-card button**: nothing is created in the trash.
|
/// - it has **no new-card button**: nothing is created in the trash.
|
||||||
///
|
///
|
||||||
/// ### No editing in the trash
|
/// ### No editing in the trash
|
||||||
|
|||||||
@@ -74,12 +74,15 @@ struct DragPayloadTests {
|
|||||||
@Suite("DragLocality")
|
@Suite("DragLocality")
|
||||||
struct DragLocalityTests {
|
struct DragLocalityTests {
|
||||||
|
|
||||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
// Instance members, not `static`: every case below names them bare, and a static member is not
|
||||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
// reachable unqualified from an instance method. Swift Testing builds a fresh instance per test,
|
||||||
|
// so these are as constant either way.
|
||||||
|
private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||||
|
private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||||
|
|
||||||
private static let none: NSEvent.ModifierFlags = []
|
private let none: NSEvent.ModifierFlags = []
|
||||||
private static let option: NSEvent.ModifierFlags = [.option]
|
private let option: NSEvent.ModifierFlags = [.option]
|
||||||
private static let command: NSEvent.ModifierFlags = [.command]
|
private let command: NSEvent.ModifierFlags = [.command]
|
||||||
|
|
||||||
@Test("Roots compare by their standardized path, so the same board is the same board")
|
@Test("Roots compare by their standardized path, so the same board is the same board")
|
||||||
func rootComparison() {
|
func rootComparison() {
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BoardStore`'s Finder file drops — the writes an external 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.
|
||||||
|
///
|
||||||
|
/// Like every other write suite here these drive a **real store over a real temp board** and read
|
||||||
|
/// back through the loader or the raw bytes, never through a snapshot the store handed out: the
|
||||||
|
/// interesting claims are about the files — which name a colliding attachment landed under, which
|
||||||
|
/// rank a created card took, and which folder was removed again when a batch failed. `WriterFixture`,
|
||||||
|
/// `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
///
|
||||||
|
/// The gesture's half — which card is under the cursor, which slot the shadows show — is
|
||||||
|
/// `BoardDropContext`'s and is not unit-testable without a live window; here the target and the index
|
||||||
|
/// are simply given, which is the same split `DragWriteTests` makes.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
private func tombstoned(order: String, title: String) -> String {
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
deleted: 2026-03-03T09:00:00Z
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three cards in the first lane, one in the second — room for a run to land at the head, between
|
||||||
|
/// siblings, or at the end without either extreme being the only answer.
|
||||||
|
@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"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files being dropped, in a directory of their own **outside the board** — a Finder drag comes
|
||||||
|
/// from somewhere else by definition, and a source sitting inside the board would make "the original
|
||||||
|
/// is never a casualty" untestable.
|
||||||
|
private struct DropSources {
|
||||||
|
let root: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("FileDropTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file at `relativePath`, creating its folder.
|
||||||
|
@discardableResult
|
||||||
|
func file(_ relativePath: String, _ bytes: Data = Data([0x01])) throws -> URL {
|
||||||
|
let url = root.appendingPathComponent(relativePath)
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try bytes.write(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A *folder* — what a Finder drag of a directory hands over, which the import machinery refuses.
|
||||||
|
@discardableResult
|
||||||
|
func folder(_ relativePath: String) throws -> URL {
|
||||||
|
let url = root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||||
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||||||
|
private let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
private let card2 = ItemID(rawValue: Ident.card2)
|
||||||
|
|
||||||
|
/// The board as the loader sees it — never the store's snapshot, which a drop deliberately does not
|
||||||
|
/// touch (the one-way flow: the write lands, the watcher reloads).
|
||||||
|
private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
|
||||||
|
try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lane(_ id: ItemID, in fixture: WriterFixture) throws -> Lane? {
|
||||||
|
try loaded(fixture).lanes.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lane's rendered card titles, in display order — `nil` for a card carrying no `title` key.
|
||||||
|
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String?] {
|
||||||
|
guard let lane = try lane(laneID, in: fixture) else { return [] }
|
||||||
|
return lane.cards.filter { !$0.isDeleted }.map(\.title.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lane's rendered cards, in display order.
|
||||||
|
private func cards(_ laneID: ItemID, in fixture: WriterFixture) throws -> [Card] {
|
||||||
|
guard let lane = try lane(laneID, in: fixture) else { return [] }
|
||||||
|
return lane.cards.filter { !$0.isDeleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Attaching to a card
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ importAttachments(toCard:)")
|
||||||
|
struct ImportAttachmentsToCardTests {
|
||||||
|
|
||||||
|
@Test("A multi-file drop lands every file in the card's attachments, originals untouched")
|
||||||
|
func multipleFilesLand() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
|
||||||
|
let notes = try sources.file("notes.txt", Data([0x41, 0x42]))
|
||||||
|
|
||||||
|
store.importAttachments([shot, notes], toCard: card1)
|
||||||
|
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data([0x41, 0x42]))
|
||||||
|
// The face's indicator reads the same listing the importer wrote.
|
||||||
|
#expect(try cards(lane1, in: fixture).first?.attachments == ["notes.txt", "shot.png"])
|
||||||
|
#expect(try Data(contentsOf: shot) == Data([0x89, 0x50]), "a copy's origin, never its casualty")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A name already taken is renamed Finder-style rather than overwritten")
|
||||||
|
func collisionsRename() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let first = try sources.file("a/shot.png", Data([0x01]))
|
||||||
|
let second = try sources.file("b/shot.png", Data([0x02]))
|
||||||
|
let third = try sources.file("c/shot.png", Data([0x03]))
|
||||||
|
|
||||||
|
// Two drops, and a two-file drop whose members collide with each other — the rename counts up
|
||||||
|
// against what is on disk at decision time either way.
|
||||||
|
store.importAttachments([first], toCard: card1)
|
||||||
|
store.importAttachments([second, third], toCard: card1)
|
||||||
|
|
||||||
|
let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
|
||||||
|
#expect(try fixture.entryNames(attachments) == ["shot 2.png", "shot 3.png", "shot.png"])
|
||||||
|
#expect(try fixture.data("\(attachments)/shot.png") == Data([0x01]))
|
||||||
|
#expect(try fixture.data("\(attachments)/shot 2.png") == Data([0x02]))
|
||||||
|
#expect(try fixture.data("\(attachments)/shot 3.png") == Data([0x03]))
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
|
||||||
|
func inertTargets() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
// A tombstoned card, and a live card under a tombstoned lane — effective liveness is
|
||||||
|
// ancestor-walked, so both render nowhere and both are inert.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
|
||||||
|
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||||
|
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png")
|
||||||
|
|
||||||
|
store.importAttachments([shot], toCard: card2) // tombstoned card
|
||||||
|
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane
|
||||||
|
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card
|
||||||
|
store.importAttachments([shot], toCard: lane1) // a lane, not a card
|
||||||
|
store.importAttachments([], toCard: card1) // nothing dropped
|
||||||
|
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/attachments"))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A source that cannot be imported banners and leaves nothing half-copied")
|
||||||
|
func aFailingSourceBanners() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png")
|
||||||
|
let directory = try sources.folder("Project")
|
||||||
|
|
||||||
|
store.importAttachments([shot, directory], toCard: card1)
|
||||||
|
|
||||||
|
// The batch stops at the folder, and what landed before it stays landed.
|
||||||
|
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
|
||||||
|
#expect(store.banners.oneShots.count == 1)
|
||||||
|
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "Project"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Creating cards from files
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ createCards(fromFiles:inLane:at:)")
|
||||||
|
struct CreateCardsFromFilesTests {
|
||||||
|
|
||||||
|
@Test("One card per file, titled without its extension, each carrying its own file")
|
||||||
|
func oneCardPerFile() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png", Data([0x01]))
|
||||||
|
let notes = try sources.file("meeting notes.txt", Data([0x02]))
|
||||||
|
|
||||||
|
// Between First and Second: the drop position, the same index the card zones produce.
|
||||||
|
store.createCards(fromFiles: [shot, notes], inLane: lane1, at: 1)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "shot", "meeting notes", "Second", "Third"])
|
||||||
|
|
||||||
|
let landed = try cards(lane1, in: fixture)
|
||||||
|
#expect(landed.count == 5)
|
||||||
|
// Fresh GUIDs — nothing was reused from the board's own identities.
|
||||||
|
let created = [landed[1], landed[2]]
|
||||||
|
#expect(!created.contains { [Ident.card1, Ident.card2, Ident.card3].contains($0.id.rawValue) })
|
||||||
|
#expect(created[0].attachments == ["shot.png"])
|
||||||
|
#expect(created[1].attachments == ["meeting notes.txt"])
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(created[0].id.rawValue)/attachments/shot.png") == Data([0x01]))
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(created[1].id.rawValue)/attachments/meeting notes.txt") == Data([0x02]))
|
||||||
|
// Ranks are inserted, never permuted: the run sits strictly between its neighbours.
|
||||||
|
#expect(created.allSatisfy { $0.order > 1024 && $0.order < 2048 })
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One board per case, deliberately: a drop does not touch the snapshot (the one-way flow — the
|
||||||
|
/// write lands, the watcher reloads), so two drops against one store would both be placed against
|
||||||
|
/// the board as it was before either of them.
|
||||||
|
@Test("The index is the drop position — head, interior, end, and past the end all land where they name")
|
||||||
|
func indexPositioning() throws {
|
||||||
|
let cases: [(index: Int, expected: [String?])] = [
|
||||||
|
(0, ["dropped", "First", "Second", "Third"]),
|
||||||
|
(2, ["First", "Second", "dropped", "Third"]),
|
||||||
|
(3, ["First", "Second", "Third", "dropped"]),
|
||||||
|
// A proposal computed against a snapshot one reload old must not trap: it clamps.
|
||||||
|
(99, ["First", "Second", "Third", "dropped"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (index, expected) in cases {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == expected, "index \(index)")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty lane takes the drop at its only position")
|
||||||
|
func emptyLane() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [try sources.file("only.md")], inLane: lane3, at: 0)
|
||||||
|
|
||||||
|
#expect(try titles(lane3, in: fixture) == ["only"])
|
||||||
|
#expect(try cards(lane3, in: fixture).first?.order == 1024, "the board convention")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A filename that trims to nothing writes no title key at all")
|
||||||
|
func blankTitleOmitsTheKey() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [try sources.file(" .png")], inLane: lane2, at: 1)
|
||||||
|
|
||||||
|
#expect(try titles(lane2, in: fixture) == ["Fourth", nil], "a missing key, never an empty string")
|
||||||
|
let created = try #require(try cards(lane2, in: fixture).last)
|
||||||
|
#expect(!(try fixture.indexText("\(Ident.lane2)/\(created.id.rawValue)").contains("title:")))
|
||||||
|
#expect(created.attachments == [" .png"])
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Exhausted midpoint precision compacts the lane, then places against the fresh ranks")
|
||||||
|
func renumberFallback() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
// Two cards sharing one rank — the duplicate-order tie, where no midpoint exists. Display
|
||||||
|
// order falls to the folder-name tie-break, so card1 renders before card2.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [try sources.file("between.txt")], inLane: lane1, at: 1)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "between", "Second"])
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048), "the lane was compacted first")
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A tombstoned, vanished, or empty destination writes nothing")
|
||||||
|
func inertDestinations() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png")
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // tombstoned
|
||||||
|
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
|
||||||
|
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped
|
||||||
|
|
||||||
|
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A file that fails mid-batch banners, keeps the cards already made, and leaves no half-made one")
|
||||||
|
func partialFailureLeavesNoHalfMadeCard() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let shot = try sources.file("shot.png")
|
||||||
|
let directory = try sources.folder("Project")
|
||||||
|
let after = try sources.file("after.txt")
|
||||||
|
|
||||||
|
store.createCards(fromFiles: [shot, directory, after], inLane: lane1, at: 3)
|
||||||
|
|
||||||
|
// The first file's card stands, the folder's card was removed again, and the batch stopped
|
||||||
|
// before the third — so the lane grew by exactly one.
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"])
|
||||||
|
let landed = try cards(lane1, in: fixture)
|
||||||
|
#expect(landed.count == 4)
|
||||||
|
#expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one")
|
||||||
|
#expect(landed.last?.attachments == ["shot.png"])
|
||||||
|
#expect(store.banners.oneShots.count == 1)
|
||||||
|
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "Project"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The title rule
|
||||||
|
|
||||||
|
@Suite("BoardStore ▸ cardTitle(forFile:)")
|
||||||
|
struct CardTitleForFileTests {
|
||||||
|
|
||||||
|
@Test("The filename without its extension — URL's split, which is Finder's")
|
||||||
|
func titles() {
|
||||||
|
func title(_ name: String) -> String? {
|
||||||
|
BoardStore.cardTitle(forFile: URL(fileURLWithPath: "/sources/\(name)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(title("shot.png") == "shot")
|
||||||
|
#expect(title("meeting notes.txt") == "meeting notes")
|
||||||
|
// A multi-dot name loses only its last component, exactly as the collision rename splits it.
|
||||||
|
#expect(title("archive.tar.gz") == "archive.tar")
|
||||||
|
#expect(title("notes") == "notes", "an extension-less name keeps all of itself")
|
||||||
|
#expect(title(".gitignore") == ".gitignore", "a dotfile is a name, not an extension")
|
||||||
|
#expect(title(" spaced .png") == "spaced", "trimmed, like every other title the app commits")
|
||||||
|
#expect(title(" .png") == nil, "trims to nothing: no title key, never an empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw reads
|
||||||
|
|
||||||
|
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
|
||||||
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).order
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
|
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
|
||||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||||
|
|
||||||
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size.
|
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. Tombstoned surfaces are inert to them, and a read-only board or an open inline editor refuses them outright.
|
||||||
|
|
||||||
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
|
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user