devlog July 21, 2026

Drag a Dockerfile onto the window

Building an image in Berthly meant opening the Build sheet and typing (or picking) a context folder and a Dockerfile path. Reasonable, but Finder already knows where your Dockerfile lives — you just dragged it there. Berthly now accepts the drag directly: drop a Dockerfile or Containerfile anywhere on the main window, sidebar included, and the Build sheet opens already pointed at the right context and file.

A Dockerfile mid-drag from Finder over Berthly's Compute view, with the 'Drop to build an image' overlay showing

Drop it, and the Build sheet opens already pointed at the right context and file:

The Build Image sheet opened after a drop, with the Build context and Dockerfile fields already filled in and the Tag field focused

The feature is simple to describe. Getting it to hold up against symlinks, a disconnected daemon, and a second drop arriving mid-resolve took three separate decisions, each one forced by something that didn’t behave the way a first pass assumed.

Validate the name you were shown, not the name you’ll build from

The acceptance rule is filename-only: Dockerfile, Containerfile, and the common prefix/suffix variants (Dockerfile.prod, backend.Dockerfile, and so on) — never file content. That’s simple enough until the dropped item is a symlink. Which name should get checked, the symlink’s own name or whatever it points at?

BuildDropResolver checks the dropped item’s own visible name first, and only resolves the symlink afterward, for the paths it hands back:

guard BuildDropValidator.isDockerfileLike(url.lastPathComponent) else {
    sawUnsupported = true
    continue
}

let resolved = url.resolvingSymlinksInPath()

A symlink named Dockerfile that points at some unrelated file should be accepted — that’s what you named it, so that’s what Berthly trusts. A symlink named build-file pointing at a real Dockerfile should be rejected on sight, without ever touching the target. Reversing the order — resolving first, validating the target’s name — would accept drops based on a name the user never actually saw in Finder.

The other symlink case worth naming: a broken symlink. resolvingSymlinksInPath() doesn’t throw for one — a missing target leaves the URL unresolved, still pointing at the symlink’s own path, confirmed empirically since resourceValues(forKeys:) and checkResourceIsReachable() don’t throw for it either. FileManager.fileExists(atPath:) is what actually tells “resolved to something real” apart from “resolved to nothing,” so that’s the check that runs before anything downstream treats the URL as usable.

No filename during the drag itself

The natural next feature is a hover state that goes green over a valid file and red over an invalid one, live, while you’re still dragging. Berthly doesn’t do this — hover only reflects whether the container daemon is connected, not whether the file under the cursor would be accepted.

That’s not a scope cut, it’s a platform limit. NSItemProvider.suggestedName — the one piece of metadata that could tell you the dragged file’s name before drop — isn’t available synchronously during a real Finder drag; by the time it resolves, the drag has already moved past the point where a cursor update would matter. The authoritative check happens once, in BuildDropResolver, on performDrop. Drop something that isn’t a Dockerfile and the window doesn’t reject the drag — it accepts it, resolves it, and then shows a transient rejection banner explaining why nothing happened.

A generation counter for the drop that arrives while the last one is still resolving

Resolution is filesystem I/O — resolvingSymlinksInPath(), resourceValues(forKeys:) — dispatched off the main actor so a slow or network-mounted volume can’t freeze the drag. That means two drops in quick succession can have their resolutions land out of order: drop A hits a slow path, drop B lands right after and resolves fast, and B’s result comes back first.

The fix is a plain integer, bumped synchronously the instant a drop starts, not after its candidates finish loading:

isDropInFlight = true
hoverState = nil
dropGeneration += 1
let generation = dropGeneration

handleBuildDrop captures that number and compares it against the current dropGeneration once its own resolution finishes; if a newer drop has already bumped the counter, the stale result is discarded instead of popping open a sheet for a drag the user has already moved past. Assigning the generation at drop-start rather than at load-completion matters specifically because loading is the variable-length step — do it the other way and a slow first drop could finish after a fast second one and be mistaken for the newer request.

One request type for every way to open the Build sheet

Drag-and-drop became a fifth way to open the Build sheet, joining the sidebar, command palette, toolbar, and builds popover. Rather than bolt on a sixth piece of state next to the existing showBuildSheet/viewedBuildJob pair, every entry point now funnels through one BuildSheetRequest:

private struct BuildSheetRequest: Identifiable {
    let id = UUID()
    var prefillTag: String?
    var prefillContext: BuildContext?
    var existingJob: BuildJob?
}

.sheet(item:) over a single optional BuildSheetRequest? means a stale prefill from one path — say, a build job you were reviewing in the popover — can’t leak into a plain click on the sidebar’s Build entry, because there’s exactly one piece of state doing the presenting, and it’s replaced wholesale on every open rather than accumulated across the different @State flags a bolt-on would have needed.

Where it lives

Drag a Dockerfile or Containerfile from Finder onto Berthly’s main window — the sidebar counts too — and the Build sheet opens with the context and Dockerfile path already filled in, cursor in the Tag field. If the daemon isn’t connected, the overlay says so instead of accepting the drop; drop something that isn’t recognized as a Dockerfile and a brief banner explains why nothing happened.

← All posts