Skip to content

Commit 8532f66

Browse files
alicodingclaude
andauthored
feat: plugin ingestion claims — drops and pastes from other apps reach plugin objects (goal 0251) (#516)
* feat: plugin ingestion claims — drops and pastes from other apps reach plugin objects (goal 0251) Manifest contributes.canvasObjects (fileExtensions + pastesURLs) is the declarative contribution point both ingestion chains consult without running plugin code: the Go paste chain gains a claims-backed last recognizer (wired through the disabled-extensions filter at the composition root), the frontend drop router gains a third-party branch behind built-ins and ahead of the card fallback, and the payload shape derives from the object's declared source (file=>mirrorPath, url=>url+host title), host-enforced at registration. Extensions rows state claims declare-first; mill-bookmark claims URL pastes as the shipping proof, e2e-proven end to end (paste a link, get a bookmark). Splits the paste door out of atlaspastebuild.go and bundles main.go's settings-era wiring calls (both files were at the 500-line limit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * chore: raise Go coverage floor 71.0 -> 75.0 (real coverage 75.7 after goal 0251's pluginsvc/atlassvc tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 41c7c8c commit 8532f66

28 files changed

Lines changed: 687 additions & 106 deletions

examples/plugins/mill-bookmark/manifest.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,8 @@
55
"description": "Keeps a web address on the board and opens it in your browser.",
66
"author": "Mill examples",
77
"minMillVersion": "0.9.0",
8-
"capabilities": ["open-url"]
8+
"capabilities": ["open-url"],
9+
"contributes": {
10+
"canvasObjects": [{ "kind": "bookmark", "pastesURLs": true }]
11+
}
912
}

frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,13 @@ export interface PasteResult {
328328
* paste names exactly one path or URL.
329329
*/
330330
"Images": number;
331+
332+
/**
333+
* PluginObjects counts a paste landing a runtime plugin's claimed
334+
* board object (docs/goals/0251, atlaspasteplugin.go) -- always 0
335+
* or 1, same single-payload property Images documents.
336+
*/
337+
"PluginObjects": number;
331338
"SkippedPages": string[] | null;
332339
}
333340

frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ export {
77
};
88

99
export type {
10+
CanvasObjectContribution,
1011
GuardedActionDecision,
1112
Manifest,
13+
ManifestContributes,
1214
PluginInfo
1315
} from "./models.js";

frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22
// This file is automatically generated. DO NOT EDIT
33

4+
/**
5+
* CanvasObjectContribution claims the ingestion doors for one canvas
6+
* object kind: which dropped-file extensions and which clipboard
7+
* shapes land as this plugin's object. Payload shape is not declared
8+
* here -- it derives from the object's own registered source (a
9+
* fileExtensions claim requires a file-backed object landing
10+
* mirrorPath+title; PastesURLs requires a url-backed one landing
11+
* url+title), enforced host-side at registration.
12+
*/
13+
export interface CanvasObjectContribution {
14+
"kind": string;
15+
"fileExtensions": string[] | null;
16+
"pastesURLs": boolean;
17+
}
18+
419
/**
520
* GuardedActionDecision is RequestGuardedAction's wire shape.
621
*/
@@ -18,9 +33,11 @@ export interface GuardedActionDecision {
1833

1934
/**
2035
* Manifest is the converged plugin manifest shape (docs/adr/0047 §1:
21-
* identity metadata + a declared capability set; contributions happen
22-
* at activate() time through the host API, so they are not restated
23-
* here).
36+
* identity metadata + a declared capability set). Rendering
37+
* contributions happen at activate() time through the host API, so
38+
* they are not restated here; INGESTION claims are the deliberate
39+
* exception (docs/goals/0251) -- both ingestion chains must consult
40+
* them without running plugin code, so they live in Contributes.
2441
*/
2542
export interface Manifest {
2643
"id": string;
@@ -30,6 +47,17 @@ export interface Manifest {
3047
"author": string;
3148
"minMillVersion": string;
3249
"capabilities": string[] | null;
50+
"contributes": ManifestContributes;
51+
}
52+
53+
/**
54+
* ManifestContributes is the manifest's declarative contribution
55+
* point (docs/goals/0251, the VSCode-shaped convention): data other
56+
* parts of Mill read to ROUTE to a plugin, distinct from capabilities
57+
* (what a plugin may ask to do).
58+
*/
59+
export interface ManifestContributes {
60+
"canvasObjects": CanvasObjectContribution[] | null;
3361
}
3462

3563
/**

frontend/e2e/runtime-plugins.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ test('the Extensions page tells the install story: plugin row with manifest meta
161161
await expect(bookmarkRow).toContainText('Bookmark')
162162
await expect(bookmarkRow).toContainText('1.0.0')
163163
await expect(bookmarkRow).toContainText('open-url')
164+
// Ingestion claims render declare-first (goal 0251): the row
165+
// states what the plugin catches before it ever runs.
166+
await expect(bookmarkRow.locator('[data-testid="extensions-plugin-catches"]')).toContainText('web links pasted')
164167
await expect(bookmarkRow.locator('[data-testid="extensions-plugin-toggle"]')).toBeVisible()
165168

166169
// A broken folder is a visible row naming its exact problem --
@@ -175,3 +178,36 @@ test('the Extensions page tells the install story: plugin row with manifest meta
175178
await close()
176179
}
177180
})
181+
182+
test('a URL pasted from another app lands as the claiming plugin object, not a note (goal 0251)', async () => {
183+
const { page, close } = await launchWithPlugins(6)
184+
try {
185+
await page.goto('/')
186+
await page.getByRole('link', { name: 'Atlas' }).click()
187+
const board = page.getByTestId('atlas-board')
188+
await expect(board).toBeVisible()
189+
190+
// The paste anchors at the pointer -- aim at open canvas first
191+
// (atlas-paste-convert.spec.ts's own cursor-position gesture).
192+
const spot = await findEmptyBoardRect(page, board, 300, 200)
193+
// eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (atlas-paste-convert.spec.ts's pasteText comment has the full reasoning)
194+
await page.mouse.move(spot.x + 20, spot.y + 20)
195+
await page.evaluate(() => {
196+
const dt = new DataTransfer()
197+
dt.setData('text/plain', 'https://example.com/some/page')
198+
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
199+
})
200+
201+
// The whole claims chain fires: manifest scan -> wiring's
202+
// enablement filter -> the Go recognizer -> a bookmark object
203+
// rendered by the plugin's own face, carrying the pasted URL.
204+
const face = page.locator('[data-testid="plugin-face-bookmark"]')
205+
await expect(face).toBeVisible()
206+
await expect(face.locator('[data-testid="bookmark-url-input"]')).toHaveValue('https://example.com/some/page')
207+
208+
// And it landed as the claimed object, never the note fallback.
209+
await expect(page.locator('[data-testid="atlas-sticky-note"]')).toHaveCount(0)
210+
} finally {
211+
await close()
212+
}
213+
})

frontend/src/atlas/atlasNounRegistry.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ export type ThirdPartyNounShape = Omit<AtlasToolShapeBase, 'boardObjectKind'> &
367367
// The owning plugin (manifest id) -- the Extensions page's join key.
368368
pluginId: string
369369
defaultPayload: Record<string, string>
370+
// Dropped-file extensions this object claims (docs/goals/0251),
371+
// joined from the manifest's contributes by the host -- lowercased
372+
// ".ext" entries the drop router compares against extensionOf().
373+
fileExtensions: string[]
370374
}
371375

372376
const thirdPartyRegistry = new Map<string, ThirdPartyNounShape>()
@@ -392,3 +396,16 @@ export function isThirdPartyToolId(id: string): boolean {
392396
export function thirdPartyNounFor(id: string): ThirdPartyNounShape | undefined {
393397
return thirdPartyRegistry.get(id)
394398
}
399+
400+
// thirdPartyNounForExtension -- the drop router's claim lookup
401+
// (docs/goals/0251). Registration order decides ties (first claimant
402+
// wins, deterministic: the loader activates plugins in ListPlugins'
403+
// sorted id order). The registry only ever holds ENABLED plugins (the
404+
// loader skips disabled ids at boot), so presence here IS enablement.
405+
export function thirdPartyNounForExtension(ext: string): ThirdPartyNounShape | undefined {
406+
const lower = ext.toLowerCase()
407+
for (const noun of thirdPartyRegistry.values()) {
408+
if (noun.fileExtensions.includes(lower)) return noun
409+
}
410+
return undefined
411+
}

frontend/src/atlas/pasteSummary.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { pasteSummaryText } from './pasteSummary'
66
const t = ((key: string, opts?: Record<string, unknown>) => (opts ? `${key}:${JSON.stringify(opts)}` : key)) as TFunction<'atlas'>
77

88
function result(overrides: Partial<PasteResult> = {}): PasteResult {
9-
return { Recognized: true, Cards: 0, Links: 0, Tables: 0, Images: 0, SkippedPages: null, ...overrides }
9+
return { Recognized: true, Cards: 0, Links: 0, Tables: 0, Images: 0, PluginObjects: 0, SkippedPages: null, ...overrides }
1010
}
1111

1212
describe('pasteSummaryText', () => {

frontend/src/atlas/pasteSummary.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function pasteSummaryText(t: TFunction<'atlas'>, res: PasteResult): strin
1010
res.Cards > 0 ? t('paste.cards', { count: res.Cards }) : '',
1111
res.Links > 0 ? t('paste.links', { count: res.Links }) : '',
1212
res.Images > 0 ? t('paste.images', { count: res.Images }) : '',
13+
res.PluginObjects > 0 ? t('paste.pluginObjects', { count: res.PluginObjects }) : '',
1314
].filter(Boolean)
1415
const summary = t('paste.converted', { what: parts.join(', ') })
1516
if (!res.SkippedPages || res.SkippedPages.length === 0) return summary

frontend/src/atlas/useAtlasNativeFileDrop.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { describe, expect, it } from 'vitest'
22
import { resolveFileDropKind } from './useAtlasNativeFileDrop'
3+
import type { ThirdPartyNounShape } from './atlasNounRegistry'
34

45
const alwaysEnabled = () => true
56
const alwaysDisabled = () => false
67

8+
// Only the fields the drop router reads -- claim-lookup tests inject
9+
// this instead of registering into the real third-party registry.
10+
const claimedNoun = { id: 'bookmark', boardObjectKind: 'bookmark', fileExtensions: ['.webloc'] } as unknown as ThirdPartyNounShape
11+
const claimLookup = (ext: string) => (claimedNoun.fileExtensions.includes(ext) ? claimedNoun : undefined)
12+
713
describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
814
it('routes a diagram path to "diagram" when the diagram extension is enabled', () => {
915
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram')
@@ -36,4 +42,22 @@ describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
3642
it('falls an unrelated extension through to "card"', () => {
3743
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card')
3844
})
45+
46+
// Plugin ingestion claims (docs/goals/0251): a manifest-claimed
47+
// extension routes to the plugin's noun -- behind every built-in
48+
// shape, ahead of the card fallback.
49+
it('routes a plugin-claimed extension to the claiming noun', () => {
50+
expect(resolveFileDropKind('/tmp/site.webloc', alwaysEnabled, claimLookup)).toBe(claimedNoun)
51+
})
52+
53+
it('never lets a plugin claim shadow a built-in shape', () => {
54+
const greedy = () => claimedNoun
55+
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled, greedy)).toBe('diagram')
56+
expect(resolveFileDropKind('/tmp/photo.png', alwaysEnabled, greedy)).toBe('image')
57+
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled, greedy)).toBe('sheet')
58+
})
59+
60+
it('falls an unclaimed extension through to "card" even with claims registered', () => {
61+
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled, claimLookup)).toBe('card')
62+
})
3963
})

frontend/src/atlas/useAtlasNativeFileDrop.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { isExtensionEnabled } from '../shared/extensionEnablementStore'
88
import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest'
99
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD } from './atlasFileDropShared'
1010
import { frameContainingPoint } from './atlasFramePoint'
11+
import { extensionOf } from './unitRegistry'
12+
import { thirdPartyNounForExtension, type ThirdPartyNounShape } from './atlasNounRegistry'
1113
import { isDiagramPath, useAtlasDiagramObjectCreate } from './useAtlasDiagramObjectCreate'
1214
import { isImagePath, useAtlasImageObjectCreate } from './useAtlasImageObjectCreate'
1315
import { isSheetPath, useAtlasSheetObjectCreate } from './useAtlasSheetObjectCreate'
@@ -19,22 +21,29 @@ const PULSE_MS_REDUCED = 1500
1921

2022
// resolveFileDropKind -- the pure routing decision behind the Events.On
2123
// handler below: which board-object kind a resolved drop path lands
22-
// as, or 'card' for the generic reference-card fallback. Pulled out as
23-
// its own function (goal 0237 S3 rider) purely so it's Vitest-testable
24-
// on its own -- the real OS drop GESTURE that reaches it is a
25-
// structural e2e gap (testing.md's own manual-only registry:
24+
// as, a runtime plugin's noun when its manifest claims the extension
25+
// (docs/goals/0251 -- always BEHIND the built-in shapes, ahead of the
26+
// card fallback, so a plugin can extend routing but never shadow a
27+
// built-in), or 'card' for the generic reference-card fallback. Pulled
28+
// out as its own function (goal 0237 S3 rider) purely so it's
29+
// Vitest-testable on its own -- the real OS drop GESTURE that reaches
30+
// it is a structural e2e gap (testing.md's own manual-only registry:
2631
// WindowFilesDropped needs a real *WebviewWindow, which server-mode
2732
// Playwright's connection is not), but this decision is plain data in,
28-
// data out. isEnabled is injected rather than read from the store
29-
// directly so a test can drive both branches without touching global
30-
// state.
33+
// data out. isEnabled and the claim lookup are injected rather than
34+
// read from the stores directly so a test can drive every branch
35+
// without touching global state.
3136
export type FileDropKind = 'diagram' | 'image' | 'sheet' | 'card'
3237

33-
export function resolveFileDropKind(path: string, isEnabled: (id: string) => boolean): FileDropKind {
38+
export function resolveFileDropKind(
39+
path: string,
40+
isEnabled: (id: string) => boolean,
41+
claimedNounForExtension: (ext: string) => ThirdPartyNounShape | undefined = thirdPartyNounForExtension,
42+
): FileDropKind | ThirdPartyNounShape {
3443
if (isDiagramPath(path) && isEnabled('diagram')) return 'diagram'
3544
if (isImagePath(path)) return 'image'
3645
if (isSheetPath(path) && isEnabled('sheet')) return 'sheet'
37-
return 'card'
46+
return claimedNounForExtension(extensionOf(path)) ?? 'card'
3847
}
3948

4049
// The board-context half of the native OS file-drop door (goal 0081
@@ -92,7 +101,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
92101
// rider -- neither noun has a tray button to hide) falls the
93102
// drop through to the plain-card path instead, exactly the
94103
// resolveFileDropKind decision above states.
95-
switch (resolveFileDropKind(path, isExtensionEnabled)) {
104+
const verdict = resolveFileDropKind(path, isExtensionEnabled)
105+
if (typeof verdict === 'object') {
106+
// A claimed-extension drop lands the plugin's own object
107+
// through the same file-backed payload contract diagram/
108+
// sheet use (mirrorPath + title) -- the file stays where
109+
// it is, the object points at it (docs/goals/0251).
110+
return AtlasService.CreateBoardObject(verdict.boardObjectKind, { ...verdict.defaultPayload, mirrorPath: path, title: titleFromFilename(path) }, { X: point.x, Y: point.y }, targetParentID)
111+
.then(() => refreshAtlas())
112+
}
113+
switch (verdict) {
96114
case 'diagram':
97115
return diagramCreate.land(path, targetParentID, { X: point.x, Y: point.y })
98116
case 'image':

0 commit comments

Comments
 (0)