Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion examples/plugins/mill-bookmark/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"description": "Keeps a web address on the board and opens it in your browser.",
"author": "Mill examples",
"minMillVersion": "0.9.0",
"capabilities": ["open-url"]
"capabilities": ["open-url"],
"contributes": {
"canvasObjects": [{ "kind": "bookmark", "pastesURLs": true }]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,13 @@ export interface PasteResult {
* paste names exactly one path or URL.
*/
"Images": number;

/**
* PluginObjects counts a paste landing a runtime plugin's claimed
* board object (docs/goals/0251, atlaspasteplugin.go) -- always 0
* or 1, same single-payload property Images documents.
*/
"PluginObjects": number;
"SkippedPages": string[] | null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export {
};

export type {
CanvasObjectContribution,
GuardedActionDecision,
Manifest,
ManifestContributes,
PluginInfo
} from "./models.js";
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

/**
* CanvasObjectContribution claims the ingestion doors for one canvas
* object kind: which dropped-file extensions and which clipboard
* shapes land as this plugin's object. Payload shape is not declared
* here -- it derives from the object's own registered source (a
* fileExtensions claim requires a file-backed object landing
* mirrorPath+title; PastesURLs requires a url-backed one landing
* url+title), enforced host-side at registration.
*/
export interface CanvasObjectContribution {
"kind": string;
"fileExtensions": string[] | null;
"pastesURLs": boolean;
}

/**
* GuardedActionDecision is RequestGuardedAction's wire shape.
*/
Expand All @@ -18,9 +33,11 @@ export interface GuardedActionDecision {

/**
* Manifest is the converged plugin manifest shape (docs/adr/0047 §1:
* identity metadata + a declared capability set; contributions happen
* at activate() time through the host API, so they are not restated
* here).
* identity metadata + a declared capability set). Rendering
* contributions happen at activate() time through the host API, so
* they are not restated here; INGESTION claims are the deliberate
* exception (docs/goals/0251) -- both ingestion chains must consult
* them without running plugin code, so they live in Contributes.
*/
export interface Manifest {
"id": string;
Expand All @@ -30,6 +47,17 @@ export interface Manifest {
"author": string;
"minMillVersion": string;
"capabilities": string[] | null;
"contributes": ManifestContributes;
}

/**
* ManifestContributes is the manifest's declarative contribution
* point (docs/goals/0251, the VSCode-shaped convention): data other
* parts of Mill read to ROUTE to a plugin, distinct from capabilities
* (what a plugin may ask to do).
*/
export interface ManifestContributes {
"canvasObjects": CanvasObjectContribution[] | null;
}

/**
Expand Down
36 changes: 36 additions & 0 deletions frontend/e2e/runtime-plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ test('the Extensions page tells the install story: plugin row with manifest meta
await expect(bookmarkRow).toContainText('Bookmark')
await expect(bookmarkRow).toContainText('1.0.0')
await expect(bookmarkRow).toContainText('open-url')
// Ingestion claims render declare-first (goal 0251): the row
// states what the plugin catches before it ever runs.
await expect(bookmarkRow.locator('[data-testid="extensions-plugin-catches"]')).toContainText('web links pasted')
await expect(bookmarkRow.locator('[data-testid="extensions-plugin-toggle"]')).toBeVisible()

// A broken folder is a visible row naming its exact problem --
Expand All @@ -175,3 +178,36 @@ test('the Extensions page tells the install story: plugin row with manifest meta
await close()
}
})

test('a URL pasted from another app lands as the claiming plugin object, not a note (goal 0251)', async () => {
const { page, close } = await launchWithPlugins(6)
try {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
const board = page.getByTestId('atlas-board')
await expect(board).toBeVisible()

// The paste anchors at the pointer -- aim at open canvas first
// (atlas-paste-convert.spec.ts's own cursor-position gesture).
const spot = await findEmptyBoardRect(page, board, 300, 200)
// 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)
await page.mouse.move(spot.x + 20, spot.y + 20)
await page.evaluate(() => {
const dt = new DataTransfer()
dt.setData('text/plain', 'https://example.com/some/page')
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
})

// The whole claims chain fires: manifest scan -> wiring's
// enablement filter -> the Go recognizer -> a bookmark object
// rendered by the plugin's own face, carrying the pasted URL.
const face = page.locator('[data-testid="plugin-face-bookmark"]')
await expect(face).toBeVisible()
await expect(face.locator('[data-testid="bookmark-url-input"]')).toHaveValue('https://example.com/some/page')

// And it landed as the claimed object, never the note fallback.
await expect(page.locator('[data-testid="atlas-sticky-note"]')).toHaveCount(0)
} finally {
await close()
}
})
17 changes: 17 additions & 0 deletions frontend/src/atlas/atlasNounRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ export type ThirdPartyNounShape = Omit<AtlasToolShapeBase, 'boardObjectKind'> &
// The owning plugin (manifest id) -- the Extensions page's join key.
pluginId: string
defaultPayload: Record<string, string>
// Dropped-file extensions this object claims (docs/goals/0251),
// joined from the manifest's contributes by the host -- lowercased
// ".ext" entries the drop router compares against extensionOf().
fileExtensions: string[]
}

const thirdPartyRegistry = new Map<string, ThirdPartyNounShape>()
Expand All @@ -392,3 +396,16 @@ export function isThirdPartyToolId(id: string): boolean {
export function thirdPartyNounFor(id: string): ThirdPartyNounShape | undefined {
return thirdPartyRegistry.get(id)
}

// thirdPartyNounForExtension -- the drop router's claim lookup
// (docs/goals/0251). Registration order decides ties (first claimant
// wins, deterministic: the loader activates plugins in ListPlugins'
// sorted id order). The registry only ever holds ENABLED plugins (the
// loader skips disabled ids at boot), so presence here IS enablement.
export function thirdPartyNounForExtension(ext: string): ThirdPartyNounShape | undefined {
const lower = ext.toLowerCase()
for (const noun of thirdPartyRegistry.values()) {
if (noun.fileExtensions.includes(lower)) return noun
}
return undefined
}
2 changes: 1 addition & 1 deletion frontend/src/atlas/pasteSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { pasteSummaryText } from './pasteSummary'
const t = ((key: string, opts?: Record<string, unknown>) => (opts ? `${key}:${JSON.stringify(opts)}` : key)) as TFunction<'atlas'>

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

describe('pasteSummaryText', () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/atlas/pasteSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function pasteSummaryText(t: TFunction<'atlas'>, res: PasteResult): strin
res.Cards > 0 ? t('paste.cards', { count: res.Cards }) : '',
res.Links > 0 ? t('paste.links', { count: res.Links }) : '',
res.Images > 0 ? t('paste.images', { count: res.Images }) : '',
res.PluginObjects > 0 ? t('paste.pluginObjects', { count: res.PluginObjects }) : '',
].filter(Boolean)
const summary = t('paste.converted', { what: parts.join(', ') })
if (!res.SkippedPages || res.SkippedPages.length === 0) return summary
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/atlas/useAtlasNativeFileDrop.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest'
import { resolveFileDropKind } from './useAtlasNativeFileDrop'
import type { ThirdPartyNounShape } from './atlasNounRegistry'

const alwaysEnabled = () => true
const alwaysDisabled = () => false

// Only the fields the drop router reads -- claim-lookup tests inject
// this instead of registering into the real third-party registry.
const claimedNoun = { id: 'bookmark', boardObjectKind: 'bookmark', fileExtensions: ['.webloc'] } as unknown as ThirdPartyNounShape
const claimLookup = (ext: string) => (claimedNoun.fileExtensions.includes(ext) ? claimedNoun : undefined)

describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
it('routes a diagram path to "diagram" when the diagram extension is enabled', () => {
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram')
Expand Down Expand Up @@ -36,4 +42,22 @@ describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
it('falls an unrelated extension through to "card"', () => {
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card')
})

// Plugin ingestion claims (docs/goals/0251): a manifest-claimed
// extension routes to the plugin's noun -- behind every built-in
// shape, ahead of the card fallback.
it('routes a plugin-claimed extension to the claiming noun', () => {
expect(resolveFileDropKind('/tmp/site.webloc', alwaysEnabled, claimLookup)).toBe(claimedNoun)
})

it('never lets a plugin claim shadow a built-in shape', () => {
const greedy = () => claimedNoun
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled, greedy)).toBe('diagram')
expect(resolveFileDropKind('/tmp/photo.png', alwaysEnabled, greedy)).toBe('image')
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled, greedy)).toBe('sheet')
})

it('falls an unclaimed extension through to "card" even with claims registered', () => {
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled, claimLookup)).toBe('card')
})
})
38 changes: 28 additions & 10 deletions frontend/src/atlas/useAtlasNativeFileDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { isExtensionEnabled } from '../shared/extensionEnablementStore'
import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest'
import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD } from './atlasFileDropShared'
import { frameContainingPoint } from './atlasFramePoint'
import { extensionOf } from './unitRegistry'
import { thirdPartyNounForExtension, type ThirdPartyNounShape } from './atlasNounRegistry'
import { isDiagramPath, useAtlasDiagramObjectCreate } from './useAtlasDiagramObjectCreate'
import { isImagePath, useAtlasImageObjectCreate } from './useAtlasImageObjectCreate'
import { isSheetPath, useAtlasSheetObjectCreate } from './useAtlasSheetObjectCreate'
Expand All @@ -19,22 +21,29 @@ const PULSE_MS_REDUCED = 1500

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

export function resolveFileDropKind(path: string, isEnabled: (id: string) => boolean): FileDropKind {
export function resolveFileDropKind(
path: string,
isEnabled: (id: string) => boolean,
claimedNounForExtension: (ext: string) => ThirdPartyNounShape | undefined = thirdPartyNounForExtension,
): FileDropKind | ThirdPartyNounShape {
if (isDiagramPath(path) && isEnabled('diagram')) return 'diagram'
if (isImagePath(path)) return 'image'
if (isSheetPath(path) && isEnabled('sheet')) return 'sheet'
return 'card'
return claimedNounForExtension(extensionOf(path)) ?? 'card'
}

// The board-context half of the native OS file-drop door (goal 0081
Expand Down Expand Up @@ -92,7 +101,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
// rider -- neither noun has a tray button to hide) falls the
// drop through to the plain-card path instead, exactly the
// resolveFileDropKind decision above states.
switch (resolveFileDropKind(path, isExtensionEnabled)) {
const verdict = resolveFileDropKind(path, isExtensionEnabled)
if (typeof verdict === 'object') {
// A claimed-extension drop lands the plugin's own object
// through the same file-backed payload contract diagram/
// sheet use (mirrorPath + title) -- the file stays where
// it is, the object points at it (docs/goals/0251).
return AtlasService.CreateBoardObject(verdict.boardObjectKind, { ...verdict.defaultPayload, mirrorPath: path, title: titleFromFilename(path) }, { X: point.x, Y: point.y }, targetParentID)
.then(() => refreshAtlas())
}
switch (verdict) {
case 'diagram':
return diagramCreate.land(path, targetParentID, { X: point.x, Y: point.y })
case 'image':
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en/atlas/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@
"links_other": "{{count}} links",
"images_one": "{{count}} image",
"images_other": "{{count}} images",
"pluginObjects_one": "{{count}} object",
"pluginObjects_other": "{{count}} objects",
"pagesSkipped_one": "{{count}} page couldn't be read: {{pages}}",
"pagesSkipped_other": "{{count}} pages couldn't be read: {{pages}}"
},
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en/views.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"installHint": "Copy a plugin's folder here, then reload.",
"noPlugins": "No plugins installed yet.",
"pluginCapabilities": "Can request: {{list}}",
"pluginCatchesFiles": "Catches {{list}} files dropped on the board",
"pluginCatchesLinks": "Catches web links pasted on the board",
"pluginDisabledNote": "Turned off. Turn on and reload to load it.",
"pluginToggleAria": "Enable {{name}}"
},
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/plugins/hostApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Icon } from '@primer/octicons-react'
import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry'
import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc'
import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models'
import { ingestionClaimMismatch } from './ingestionClaims'
import { collectPluginCommand } from './pluginCommands'
import { pluginFaceComponent } from './PluginFaceContent'
import type { CanvasObjectDecl, MillPluginAPI } from './sdk'
Expand Down Expand Up @@ -42,12 +43,16 @@ export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPlu
if (!SOURCES.has(decl.source)) throw new Error(`plugin ${pluginId}: unknown source "${decl.source}"`)
if (!EDIT_ROUTES.has(decl.editRoute)) throw new Error(`plugin ${pluginId}: unknown editRoute "${decl.editRoute}"`)
if (typeof decl.renderFace !== 'function') throw new Error(`plugin ${pluginId}: renderFace must be a function`)
const contribution = (manifest.contributes?.canvasObjects ?? []).find((c) => c.kind === decl.kind)
const claimError = ingestionClaimMismatch(contribution, decl.source)
if (claimError) throw new Error(`plugin ${pluginId}: ${claimError}`)
registerThirdPartyNoun({
id: decl.kind,
interaction: 'arm-then-click',
thirdParty: true,
pluginId,
defaultPayload: { ...(decl.defaultPayload ?? {}) },
fileExtensions: (contribution?.fileExtensions ?? []).map((e) => e.toLowerCase()),
icon: emojiIcon(decl.icon),
label: decl.label,
nounName: decl.label,
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/plugins/ingestionClaims.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { ingestionClaimMismatch } from './ingestionClaims'
import type { CanvasObjectContribution } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models'

const claim = (partial: Partial<CanvasObjectContribution>): CanvasObjectContribution =>
({ kind: 'bookmark', fileExtensions: null, pastesURLs: false, ...partial }) as CanvasObjectContribution

describe('ingestionClaimMismatch (goal 0251)', () => {
it('accepts an object with no contribution at all', () => {
expect(ingestionClaimMismatch(undefined, 'board-local')).toBeNull()
})

it('accepts matched pairings', () => {
expect(ingestionClaimMismatch(claim({ fileExtensions: ['.webloc'] }), 'file')).toBeNull()
expect(ingestionClaimMismatch(claim({ pastesURLs: true }), 'url')).toBeNull()
expect(ingestionClaimMismatch(claim({}), 'board-local')).toBeNull()
})

it('rejects a file-extension claim on a non-file source, naming the kind', () => {
const err = ingestionClaimMismatch(claim({ fileExtensions: ['.webloc'] }), 'url')
expect(err).toContain('bookmark')
expect(err).toContain('"file"')
})

it('rejects a pasted-links claim on a non-url source, naming the kind', () => {
const err = ingestionClaimMismatch(claim({ pastesURLs: true }), 'board-local')
expect(err).toContain('bookmark')
expect(err).toContain('"url"')
})
})
20 changes: 20 additions & 0 deletions frontend/src/plugins/ingestionClaims.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { CanvasObjectContribution } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models'

// ingestionClaimMismatch enforces the claim/source pairing
// (docs/goals/0251): an ingestion claim's payload shape derives from
// the object's declared source (fileExtensions land mirrorPath on a
// file-backed object; pastesURLs lands url on a url-backed one), so a
// mismatched pairing would route content into a payload key the
// object never reads. Failing the LOAD with a stated reason keeps the
// claim from being silently dead. Its own module (type-only bindings
// import) so the unit test never evaluates the runtime-bound host API.
export function ingestionClaimMismatch(contribution: CanvasObjectContribution | undefined, source: 'board-local' | 'url' | 'file'): string | null {
if (!contribution) return null
if ((contribution.fileExtensions?.length ?? 0) > 0 && source !== 'file') {
return `the manifest claims file extensions for "${contribution.kind}", so its source must be "file"`
}
if (contribution.pastesURLs && source !== 'url') {
return `the manifest claims pasted links for "${contribution.kind}", so its source must be "url"`
}
return null
}
Loading
Loading