Skip to content

Commit 2d7b9b5

Browse files
alicodingclaude
andauthored
feat: file-promise drops land — the screenshot thumbnail reaches the board (goal 0256) (#534)
A drag whose pasteboard carries only a file PROMISE (macOS's post-screenshot floating thumbnail, an image dragged out of a browser page) was refused before Mill saw it: the windowing toolkit's darwin drag view registers NSFilenamesPboardType only, so AppKit showed the no-entry cursor. No upstream support exists in v2 or v3. Mill-side receiver, no fork: MillFilePromiseDropView (windowing adapter, the sole toolkit port) registers NSFilePromiseReceiver's own readableDraggedTypes and sits BELOW every existing subview -- AppKit targets the frontmost registered candidate, so any drag carrying real filenames keeps matching the toolkit's own view exactly as before, untouched by construction. Receipt materializes each promised file into a per-drop temp dir on a background queue; one exported Go callback delivers paths + the drop point, and WireFileDropWindow emits the same file-drop event with empty context. The frontend's two drop consumers resolve that empty context by DOM hit-test at the drop coordinates (resolveDropContext, Vitest-pinned), so a promise drop routes by where it actually landed -- board or card page. The image landing pipeline already byte-copies into the captures dir, so the temp file's lifetime never matters downstream. The attach is main-thread-marshaled (runMainThreadAction) and fail-safe on a missing window; server/non-darwin builds get a no-op stub. The real drag gesture is AppKit-session-bound end to end -- testing.md's manual registry carries the installed-build matrix (thumbnail drag, browser-image drag, and a Finder-file control drag). Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5aad980 commit 2d7b9b5

12 files changed

Lines changed: 320 additions & 18 deletions

File tree

.claude/rules/testing.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,18 @@ at:
288288
(clipboard screenshot) → ⌘V on the board lands the image at the
289289
pointer; Finder ⌘C a .png → ⌘V lands an image object mirroring
290290
the REAL file path; Finder ⌘C a .md → ⌘V lands a card, same as
291-
dropping it. Known-refused, not a defect: dragging the
292-
post-screenshot floating thumbnail shows the no-entry cursor
293-
(upstream file-promise gap, BACKLOG 0P0-PROMISE).
291+
dropping it.
292+
- **File-promise drops: the post-screenshot floating thumbnail**
293+
(goal 0256, `MillFilePromiseDropView` /
294+
`AttachFilePromiseReceiver`) — a promise drag needs a real AppKit
295+
drag session end to end; no harness can synthesize one. Verify on
296+
an installed build: ⌘⇧4 a region, drag the floating THUMBNAIL
297+
(before it saves) onto the board — the image object lands at the
298+
drop point, no no-entry cursor; drag an image out of a browser
299+
page — same; then drag a real file from Finder and confirm it
300+
still lands exactly as before (the promise view sits BELOW the
301+
toolkit's own drag view precisely so filename-carrying drags
302+
never reroute).
294303

295304
**Tests drive user primitives, not synthetic events.** An interaction
296305
test reaches behavior through the same primitives a user has — real
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { resolveDropContext } from './atlasFileDropShared'
3+
4+
// resolveDropContext (goal 0256): the toolkit's own file-drop event
5+
// carries a hit-tested context attribute; the file-promise receiver
6+
// delivers coordinates only, so its empty-context payloads resolve
7+
// through a DOM hit-test at the drop point instead. The suite runs in
8+
// the node environment (no DOM package installed -- deliberate), so
9+
// `document` is stubbed to exactly the two calls the helper makes:
10+
// elementFromPoint, then closest().getAttribute() on its result.
11+
function stubDocument(elementAtPoint: unknown) {
12+
const elementFromPoint = vi.fn(() => elementAtPoint)
13+
vi.stubGlobal('document', { elementFromPoint })
14+
return elementFromPoint
15+
}
16+
17+
function elementInsideTarget(context: string | null) {
18+
return {
19+
closest: (selector: string) => {
20+
if (selector !== '[data-file-drop-context]' || context === null) return null
21+
return { getAttribute: () => context }
22+
},
23+
}
24+
}
25+
26+
describe('resolveDropContext', () => {
27+
afterEach(() => {
28+
vi.unstubAllGlobals()
29+
})
30+
31+
it('returns the payload context when the toolkit supplied one, never hit-testing', () => {
32+
const elementFromPoint = stubDocument(null)
33+
expect(resolveDropContext({ context: 'board', x: 10, y: 10 })).toBe('board')
34+
expect(elementFromPoint).not.toHaveBeenCalled()
35+
})
36+
37+
it('hit-tests the drop point when the context is empty, walking up to the declared target', () => {
38+
stubDocument(elementInsideTarget('card-page'))
39+
expect(resolveDropContext({ context: '', x: 5, y: 5 })).toBe('card-page')
40+
})
41+
42+
it('returns null when the point hits no declared drop target', () => {
43+
stubDocument(elementInsideTarget(null))
44+
expect(resolveDropContext({ x: 5, y: 5 })).toBeNull()
45+
})
46+
47+
it('returns null when the point hits nothing at all', () => {
48+
stubDocument(null)
49+
expect(resolveDropContext({ x: 5, y: 5 })).toBeNull()
50+
})
51+
52+
it('returns null without hit-testing when the payload carries no coordinates', () => {
53+
const elementFromPoint = stubDocument(null)
54+
expect(resolveDropContext({})).toBeNull()
55+
expect(elementFromPoint).not.toHaveBeenCalled()
56+
})
57+
})

frontend/src/atlas/atlasFileDropShared.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,17 @@
1111
export const FILE_DROP_EVENT_NAME = 'atlas-native-file-drop'
1212
export const FILE_DROP_CONTEXT_BOARD = 'board'
1313
export const FILE_DROP_CONTEXT_CARD_PAGE = 'card-page'
14+
15+
// resolveDropContext answers WHERE a native drop landed: the payload's
16+
// own context when the toolkit's attribute hit-test supplied one, else
17+
// a DOM hit-test at the drop coordinates -- the file-promise receiver
18+
// (goal 0256) delivers materialized paths plus coordinates but no
19+
// attribute walk, so its payloads arrive with an empty context. Null
20+
// when the point hits no declared drop target (the drop is ignored,
21+
// same as the toolkit answering no attributes).
22+
export function resolveDropContext(payload: { context?: string; x?: number; y?: number }): string | null {
23+
if (payload.context) return payload.context
24+
if (payload.x === undefined || payload.y === undefined) return null
25+
const el = document.elementFromPoint(payload.x, payload.y)
26+
return el?.closest('[data-file-drop-context]')?.getAttribute('data-file-drop-context') ?? null
27+
}

frontend/src/atlas/useAtlasCardPageFileDrop.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { AtlasService } from '../shared/bindings'
66
import { titleFromFilename } from './atlasCreateHelpers'
77
import { freeChildPosition } from './atlasContainmentPlacement'
88
import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest'
9-
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_CARD_PAGE } from './atlasFileDropShared'
9+
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_CARD_PAGE, resolveDropContext } from './atlasFileDropShared'
1010

1111
// The card-foremost half of the native OS file-drop door (D5, LOCKED
1212
// design): while a card's page is open, a dropped file becomes a
@@ -36,8 +36,9 @@ export function useAtlasCardPageFileDrop({ card, allCards, onSaved, onError }: {
3636

3737
useEffect(() => {
3838
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
39-
const payload = evt.data as { filenames?: string[]; context?: string } | undefined
40-
if (!payload || payload.context !== FILE_DROP_CONTEXT_CARD_PAGE || !payload.filenames?.length) return
39+
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
40+
if (!payload || !payload.filenames?.length) return
41+
if (resolveDropContext(payload) !== FILE_DROP_CONTEXT_CARD_PAGE) return
4142
const { card: openCard, allCards: cards, onSaved: saved, onError: fail, requestFolderImport: request } = stateRef.current
4243

4344
AtlasService.ResolveFileDropRoute(payload.filenames)

frontend/src/atlas/useAtlasNativeFileDrop.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { titleFromFilename } from './atlasCreateHelpers'
66
import { refreshAtlas } from './atlasStore'
77
import { isExtensionEnabled } from '../shared/extensionEnablementStore'
88
import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest'
9-
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD } from './atlasFileDropShared'
9+
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD, resolveDropContext } from './atlasFileDropShared'
1010
import { frameContainingPoint } from './atlasFramePoint'
1111
import { extensionOf } from './unitRegistry'
1212
import { thirdPartyNounForExtension, type ThirdPartyNounShape } from './atlasNounRegistry'
@@ -137,7 +137,8 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
137137
useEffect(() => {
138138
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
139139
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
140-
if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
140+
if (!payload || !payload.filenames?.length) return
141+
if (resolveDropContext(payload) !== FILE_DROP_CONTEXT_BOARD) return
141142
void landFiles(payload.filenames, { x: payload.x ?? 0, y: payload.y ?? 0 })
142143
.catch(() => setDropError(t('capture.dropError')))
143144
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//go:build darwin && !server
2+
3+
package windowing
4+
5+
/*
6+
#cgo CFLAGS: -mmacosx-version-min=10.13 -x objective-c
7+
#cgo LDFLAGS: -framework Foundation -framework AppKit
8+
9+
#include "filepromise_darwin.h"
10+
*/
11+
import "C"
12+
13+
import (
14+
"sync"
15+
"unsafe"
16+
)
17+
18+
// promiseDropFn holds the one registered promise-drop callback -- a
19+
// package var, not per-window state, since only the main window is
20+
// ever wired (the same singular-resource shape clipboard's own
21+
// selfWriteText carries).
22+
var (
23+
promiseDropMu sync.Mutex
24+
promiseDropFn func(paths []string, x, y int)
25+
)
26+
27+
//export millFilePromiseDropped
28+
func millFilePromiseDropped(paths **C.char, count C.int, x C.int, y C.int) {
29+
n := int(count)
30+
goPaths := make([]string, 0, n)
31+
for _, p := range unsafe.Slice(paths, n) {
32+
goPaths = append(goPaths, C.GoString(p))
33+
}
34+
promiseDropMu.Lock()
35+
fn := promiseDropFn
36+
promiseDropMu.Unlock()
37+
if fn == nil {
38+
return
39+
}
40+
// The caller is a GCD completion block, not a Go-owned thread --
41+
// hop onto a goroutine so the callback can do arbitrary Go work
42+
// (event emits, service calls) without holding the C side.
43+
go fn(goPaths, int(x), int(y))
44+
}
45+
46+
// AttachFilePromiseReceiver installs the file-promise drop view on
47+
// win and registers fn to receive each materialized drop's temp-file
48+
// paths plus the drop point (top-left webview coordinates, matching
49+
// the toolkit's own file-drop event). One callback app-wide; the last
50+
// registration wins. See filepromise_darwin.m's own view comment for
51+
// why this cannot interfere with ordinary file drops.
52+
func (win *Window) AttachFilePromiseReceiver(fn func(paths []string, x, y int)) {
53+
promiseDropMu.Lock()
54+
promiseDropFn = fn
55+
promiseDropMu.Unlock()
56+
runMainThreadAction("AttachFilePromiseReceiver", func() {
57+
// NativeWindow is a stored-pointer read (nil when the window
58+
// is destroyed or not yet realized -- the C side guards nil);
59+
// read inside the marshal so the attach sees the freshest
60+
// window state.
61+
C.millAttachPromiseView(win.w.NativeWindow())
62+
})
63+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build darwin && !server
2+
3+
#ifndef MILL_FILEPROMISE_DARWIN_H
4+
#define MILL_FILEPROMISE_DARWIN_H
5+
6+
// Installs the file-promise drop view on the given NSWindow* -- must
7+
// run on the AppKit main thread (the Go caller marshals through
8+
// runMainThreadAction). Implementation and design reasoning live in
9+
// filepromise_darwin.m.
10+
void millAttachPromiseView(void *nsWindowPtr);
11+
12+
#endif
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//go:build darwin && !server
2+
3+
#import <AppKit/AppKit.h>
4+
#include <stdlib.h>
5+
#include <string.h>
6+
#include "filepromise_darwin.h"
7+
8+
// The Go-side receiver (filepromise_darwin.go's //export).
9+
extern void millFilePromiseDropped(char** paths, int count, int x, int y);
10+
11+
// MillFilePromiseDropView receives FILE-PROMISE drags (the macOS
12+
// post-screenshot floating thumbnail, a browser image drag) that the
13+
// toolkit's own drag view structurally refuses -- it registers only
14+
// NSFilenamesPboardType, so a promise-only pasteboard never matches it
15+
// (docs/goals/0256). Registration here is promise-types-only, and the
16+
// view is inserted BELOW every existing subview: AppKit targets the
17+
// frontmost registered candidate under the pointer, so any drag that
18+
// carries real filenames keeps matching the toolkit's own topmost view
19+
// exactly as before -- existing drops are untouched by construction.
20+
@interface MillFilePromiseDropView : NSView
21+
@end
22+
23+
@implementation MillFilePromiseDropView
24+
25+
// Mouse events pass through to the webview underneath -- drag delivery
26+
// uses registerForDraggedTypes, not hitTest (same contract as the
27+
// toolkit's own drag view).
28+
- (NSView *)hitTest:(NSPoint)point {
29+
return nil;
30+
}
31+
32+
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
33+
return NSDragOperationCopy;
34+
}
35+
36+
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
37+
return NSDragOperationCopy;
38+
}
39+
40+
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
41+
NSPasteboard *pb = [sender draggingPasteboard];
42+
NSArray<NSFilePromiseReceiver *> *receivers =
43+
[pb readObjectsForClasses:@[[NSFilePromiseReceiver class]] options:@{}];
44+
if (receivers.count == 0) {
45+
return NO;
46+
}
47+
48+
NSString *destDir = [NSTemporaryDirectory() stringByAppendingPathComponent:
49+
[NSString stringWithFormat:@"mill-promise-drop-%@", [[NSUUID UUID] UUIDString]]];
50+
if (![[NSFileManager defaultManager] createDirectoryAtPath:destDir
51+
withIntermediateDirectories:YES
52+
attributes:nil
53+
error:nil]) {
54+
return NO;
55+
}
56+
NSURL *destURL = [NSURL fileURLWithPath:destDir isDirectory:YES];
57+
58+
// Same top-left coordinate math as the toolkit's own drag view --
59+
// the frontend hit-tests these against document.elementFromPoint.
60+
NSPoint pWin = [sender draggingLocation];
61+
NSPoint pView = [self convertPoint:pWin fromView:nil];
62+
CGFloat contentHeight = self.window.contentView.frame.size.height;
63+
int x = (int)pView.x;
64+
int y = (int)(contentHeight - pView.y);
65+
66+
// Receipt is asynchronous BY the API's own contract (the promise
67+
// source writes the file on its own schedule); the reader blocks
68+
// run on this background queue, never the main thread, and the
69+
// dispatch group fires the one Go callback once every promised
70+
// file has either landed or errored.
71+
NSOperationQueue *queue = [NSOperationQueue new];
72+
dispatch_group_t group = dispatch_group_create();
73+
NSMutableArray<NSString *> *landed = [NSMutableArray array];
74+
NSLock *lock = [NSLock new];
75+
for (NSFilePromiseReceiver *receiver in receivers) {
76+
dispatch_group_enter(group);
77+
[receiver receivePromisedFilesAtDestination:destURL
78+
options:@{}
79+
operationQueue:queue
80+
reader:^(NSURL *fileURL, NSError *error) {
81+
if (error == nil && fileURL != nil) {
82+
[lock lock];
83+
[landed addObject:fileURL.path];
84+
[lock unlock];
85+
}
86+
dispatch_group_leave(group);
87+
}];
88+
}
89+
dispatch_group_notify(group, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
90+
NSUInteger count = landed.count;
91+
if (count == 0) {
92+
return;
93+
}
94+
char **cArr = (char **)malloc(sizeof(char *) * count);
95+
for (NSUInteger i = 0; i < count; i++) {
96+
cArr[i] = strdup([landed[i] UTF8String]);
97+
}
98+
millFilePromiseDropped(cArr, (int)count, x, y);
99+
for (NSUInteger i = 0; i < count; i++) {
100+
free(cArr[i]);
101+
}
102+
free(cArr);
103+
});
104+
return YES;
105+
}
106+
107+
@end
108+
109+
void millAttachPromiseView(void *nsWindowPtr) {
110+
// Fail-safe, never fail-crash: a missing window/contentView means
111+
// no promise support this launch, not an abort at startup.
112+
if (nsWindowPtr == NULL) {
113+
return;
114+
}
115+
NSWindow *win = (__bridge NSWindow *)nsWindowPtr;
116+
NSView *content = win.contentView;
117+
if (content == nil) {
118+
return;
119+
}
120+
MillFilePromiseDropView *v = [[MillFilePromiseDropView alloc] initWithFrame:content.bounds];
121+
v.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
122+
[v registerForDraggedTypes:[NSFilePromiseReceiver readableDraggedTypes]];
123+
[content addSubview:v positioned:NSWindowBelow relativeTo:nil];
124+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !darwin || server
2+
3+
package windowing
4+
5+
// AttachFilePromiseReceiver is a no-op off macOS and in server mode --
6+
// file-promise drags are an AppKit-only construct, and server mode has
7+
// no native window to attach to (filepromise_darwin.go carries the
8+
// real implementation and the design's own reasoning).
9+
func (win *Window) AttachFilePromiseReceiver(fn func(paths []string, x, y int)) {}

internal/services/atlassvc/atlasservice_filedrop.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,12 @@ func (a *AtlasService) WireFileDropWindow(window *windowing.Window) {
320320
}
321321
windowing.Emit(FileDropEventName, payload)
322322
})
323+
// File-PROMISE drags (the post-screenshot floating thumbnail, a
324+
// browser image drag -- goal 0256) arrive through Mill's own
325+
// receiver with materialized temp-file paths but no attribute
326+
// hit-test; Context stays empty and the frontend resolves it from
327+
// the drop coordinates (atlasFileDropShared's resolveDropContext).
328+
window.AttachFilePromiseReceiver(func(paths []string, x, y int) {
329+
windowing.Emit(FileDropEventName, FileDropPayload{Filenames: paths, X: x, Y: y})
330+
})
323331
}

0 commit comments

Comments
 (0)