Skip to content

Commit 4e07dee

Browse files
alicodingclaude
andcommitted
fix: every registered extension appears -- the list derives from nouns, not tray tools (goal 0237)
Settings > Extensions was built off ATLAS_TOOLS (tray tools only), so diagram and sheet -- both file-drop-only nouns with no tray identity -- were invisible even though they're fully registered canvas nouns. The list now derives from every registered noun: tray tools plus tool-less nouns (diagram, sheet), normalized into one ExtensionRowSource shape (extensionMeta.ts) so ExtensionRow.tsx never branches on which kind of noun it's rendering. Diagram and sheet declare their own icon/label/description/disableScopeNote via a new optional `extension` field on AtlasNounContent, read back by atlasNounRegistry.ts's toolLessNounExtensions(). Disabling a tool-less noun has no tray button to remove, so its row states the honest, narrower scope: it gates useAtlasNativeFileDrop.ts's own drop routing (extracted into the pure, testable resolveFileDropKind) and, for diagram, dispatchObjectEdit's embedded-engine door (already keyed off object.Kind since goal 0244). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
1 parent c60e25f commit 4e07dee

13 files changed

Lines changed: 380 additions & 77 deletions

frontend/e2e/settings-extensions.spec.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@ import { clickAtlasTrayTool } from './fixtures/atlasTray'
44
import { deleteViaContextMenu, shapeDrawPoints, shapeObjects } from './fixtures/atlasShapeTool'
55
import { paletteDialog } from './fixtures/palette'
66

7-
// Settings > Extensions (goal 0237 S2): a registry-derived list of
8-
// every canvas tool, each toggleable off. Shared pool: the only global
9-
// state this spec writes (Shape's own disabled flag) is restored to
10-
// its default (enabled) before the file ends, same cleanup discipline
11-
// display-density.spec.ts already establishes for a Settings toggle in
12-
// the shared pool; every board object created here is deleted here.
7+
// Settings > Extensions (goal 0237 S2, extended by goal 0237 S3's
8+
// rider): a registry-derived list of every registered canvas NOUN --
9+
// every tray tool plus every tool-less noun (diagram, sheet -- native
10+
// file-drop only, no tray button), each toggleable off. Shared pool:
11+
// the only global state this spec writes (Shape's own disabled flag)
12+
// is restored to its default (enabled) before the file ends, same
13+
// cleanup discipline display-density.spec.ts already establishes for a
14+
// Settings toggle in the shared pool; every board object created here
15+
// is deleted here.
16+
//
17+
// Disabling diagram/sheet gates useAtlasNativeFileDrop.ts's own drop
18+
// routing (a disabled drop falls through to the plain-card path). The
19+
// OS drop GESTURE itself is a structural e2e gap (testing.md's own
20+
// manual-only registry: WindowFilesDropped needs a real
21+
// *WebviewWindow, which server-mode Playwright's connection is not),
22+
// so the routing DECISION is proven at the honest layer instead --
23+
// useAtlasNativeFileDrop.test.ts's resolveFileDropKind Vitest suite --
24+
// and this spec only proves the row/toggle exists and states its
25+
// narrower scope.
1326

1427
async function openExtensionsSection(page: import('@playwright/test').Page) {
1528
await page.getByRole('link', { name: 'Settings' }).click()
@@ -59,8 +72,10 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest
5972
await toggleAll.click()
6073
await expect(toggleAll).toHaveText('Turn all on')
6174

62-
// Every row but card now shows its toggle off.
63-
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) {
75+
// Every row but card now shows its toggle off -- including the
76+
// tool-less nouns (diagram, sheet), which have no tray button to
77+
// empty but still participate in the bulk toggle.
78+
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) {
6479
const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button')
6580
await expect(toggle).toHaveAttribute('data-checked', 'false')
6681
}
@@ -80,7 +95,7 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest
8095
await expect(toggleAll).toHaveText('Turn all on')
8196
await toggleAll.click()
8297
await expect(toggleAll).toHaveText('Turn all off')
83-
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) {
98+
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) {
8499
const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button')
85100
await expect(toggle).toHaveAttribute('data-checked', 'true')
86101
}
@@ -90,9 +105,10 @@ test('Extensions section lists every registered canvas tool; the built-in card r
90105
await page.goto('/')
91106
await openExtensionsSection(page)
92107

93-
// Every ATLAS_TOOLS member (atlas/atlasTools.ts) gets exactly one
94-
// row -- card, note, area, table, image, pencil, eraser, laser, shape.
95-
await expect(page.getByTestId('extensions-row')).toHaveCount(9)
108+
// Every ATLAS_TOOLS member (atlas/atlasTools.ts) plus every
109+
// tool-less noun (diagram, sheet) gets exactly one row -- card, note,
110+
// area, table, image, pencil, eraser, laser, shape, diagram, sheet.
111+
await expect(page.getByTestId('extensions-row')).toHaveCount(11)
96112

97113
const cardRow = page.locator('[data-testid="extensions-row"][data-extension-id="card"]')
98114
await expect(cardRow).toBeVisible()
@@ -105,6 +121,40 @@ test('Extensions section lists every registered canvas tool; the built-in card r
105121
await expect(tableToggle).toHaveAttribute('data-checked', 'true')
106122
})
107123

124+
test('A tool-less noun (diagram, sheet) gets a row with a toggle and states its narrower disable scope', async ({ page }) => {
125+
await page.goto('/')
126+
await openExtensionsSection(page)
127+
128+
const diagramRow = page.locator('[data-testid="extensions-row"][data-extension-id="diagram"]')
129+
await expect(diagramRow).toBeVisible()
130+
await expect(diagramRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true')
131+
await diagramRow.locator('summary').click()
132+
await expect(diagramRow.getByTestId('extensions-row-description')).toHaveText(
133+
'View and edit diagrams — draw.io files open in the real editor.',
134+
)
135+
await expect(diagramRow.getByTestId('extensions-row-disable-scope')).toHaveText(
136+
'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.',
137+
)
138+
139+
const sheetRow = page.locator('[data-testid="extensions-row"][data-extension-id="sheet"]')
140+
await expect(sheetRow).toBeVisible()
141+
await expect(sheetRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true')
142+
await sheetRow.locator('summary').click()
143+
await expect(sheetRow.getByTestId('extensions-row-description')).toHaveText(
144+
'Preview spreadsheets and CSV files dropped onto the board.',
145+
)
146+
await expect(sheetRow.getByTestId('extensions-row-disable-scope')).toHaveText(
147+
'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.',
148+
)
149+
150+
// A tray tool's row never shows a disable-scope note -- its toggle's
151+
// scope (tray button + palette command) is already the standing
152+
// default every row implicitly shares.
153+
const tableRow = page.locator('[data-testid="extensions-row"][data-extension-id="table"]')
154+
await tableRow.locator('summary').click()
155+
await expect(tableRow.getByTestId('extensions-row-disable-scope')).toHaveCount(0)
156+
})
157+
108158
test('Disabling a tool removes it from the tray and palette, keeps existing objects rendering, and persists across reload', async ({ page }) => {
109159
await page.goto('/')
110160
await page.getByRole('link', { name: 'Atlas' }).click()

frontend/src/atlas/atlasNounDeclarationFields.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
{
7878
"field": "content",
7979
"legalValues": "an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null",
80-
"meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it."
80+
"meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy -- it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote) so Settings > Extensions can still render an honest row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it."
8181
},
8282
{
8383
"field": "capabilities",

frontend/src/atlas/atlasNounRegistry.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,32 @@ export interface AtlasNounContent {
9393
// RESOLVER -- see EditRouteDecl's own header for why a single Kind
9494
// (diagram) needs the function form.
9595
editRoute?: EditRouteDecl
96+
// extension (goal 0237 S3 rider): Settings > Extensions row metadata
97+
// for a NOUN WITH NO TRAY TOOL. A tool-bearing noun already carries
98+
// icon/label/description on its own AtlasToolShapeBase descriptor
99+
// (registerNoun below), so this stays undefined there; a tool-less
100+
// noun (diagram, sheet -- file-drop only, no AtlasToolShape to
101+
// declare these on) sets it directly in its own registerBoardObjectContent
102+
// call so the Extensions list can render an honest row for it too,
103+
// mirroring goal 0211's own description-field precedent. Presence of
104+
// this field is exactly how toolLessNounExtensions() below finds a
105+
// tool-less noun worth listing.
106+
extension?: ExtensionRowMeta
107+
}
108+
109+
// ExtensionRowMeta -- the fields ExtensionRow.tsx needs for a tool-less
110+
// noun's own row that AtlasToolShapeBase would otherwise supply.
111+
// disableScopeNote is REQUIRED (never optional): a tool-less noun has
112+
// no tray button to hide, so its own disable toggle gates a narrower,
113+
// noun-specific seam (file-drop routing, and for diagram the embedded-
114+
// editor door) -- the row must always say so rather than silently
115+
// implying the same tray-wide scope a tool row's toggle has.
116+
export interface ExtensionRowMeta {
117+
icon: Icon
118+
label: string
119+
description: string
120+
disableScopeNote: string
121+
capabilities?: readonly string[]
96122
}
97123

98124
// AtlasBoardObjectContent -- AtlasNounContent plus the board-facts
@@ -110,7 +136,7 @@ export interface AtlasNounContent {
110136
// than independently settable -- a Kind with no source still declares
111137
// this field directly (shape/ink today), so the field itself stays
112138
// required.
113-
interface AtlasBoardObjectContent extends AtlasNounContent {
139+
export interface AtlasBoardObjectContent extends AtlasNounContent {
114140
dragBand: boolean
115141
fileBacked: boolean
116142
}
@@ -146,6 +172,34 @@ export function boardObjectContentFor(kind: string): AtlasBoardObjectContent | u
146172
return boardObjectContentRegistry.get(kind as AtlasBoardObjectKind)
147173
}
148174

175+
// ToolLessNounExtension -- one entry of toolLessNounExtensions() below,
176+
// with `extension` already narrowed to non-optional (the filter that
177+
// builds this array is the one place that check happens, so every
178+
// consumer downstream gets a guaranteed ExtensionRowMeta instead of
179+
// re-checking for undefined itself).
180+
export interface ToolLessNounExtension {
181+
kind: AtlasBoardObjectKind
182+
content: AtlasBoardObjectContent
183+
extension: ExtensionRowMeta
184+
}
185+
186+
// toolLessNounExtensions -- every registered noun with NO AtlasToolShape
187+
// of its own (diagram, sheet: file-drop only) that has declared
188+
// Extensions-row metadata, so Settings > Extensions (ExtensionsSection.tsx)
189+
// can list it alongside every tray tool. A tool-bearing noun's content
190+
// also lives in this same registry (registerNoun folds it in below) but
191+
// never sets `extension`, so it's excluded here -- it already gets its
192+
// own row from ATLAS_TOOLS directly. Sorted by kind for a stable,
193+
// deterministic row order independent of import.meta.glob's own
194+
// alphabetical file-discovery order.
195+
export function toolLessNounExtensions(): ToolLessNounExtension[] {
196+
const found: ToolLessNounExtension[] = []
197+
for (const [kind, content] of boardObjectContentRegistry.entries()) {
198+
if (content.extension) found.push({ kind, content, extension: content.extension })
199+
}
200+
return found.sort((a, b) => a.kind.localeCompare(b.kind))
201+
}
202+
149203
interface AtlasToolShapeBase {
150204
icon: Icon
151205
label: string

frontend/src/atlas/tools/diagramNoun.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lazy } from 'react'
2+
import { FlowchartIcon } from '@primer/octicons-react'
23
import type { BoardObject } from '../../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
34
import { registerBoardObjectContent } from '../atlasNounRegistry'
45
import { isDrawioEditableExtension } from '../atlasDiagramMirror'
@@ -45,4 +46,16 @@ registerBoardObjectContent('diagram', {
4546
? { kind: 'embedded-engine', engine: 'drawio' }
4647
: { kind: 'external-app' }
4748
),
49+
// extension (goal 0237 S3 rider): the Settings > Extensions row for
50+
// this tool-less noun. disableScopeNote states its scope honestly --
51+
// there is no tray button to hide, so the toggle instead gates
52+
// useAtlasNativeFileDrop.ts's own routing (a disabled drop falls
53+
// through to the plain card path) and dispatchObjectEdit's
54+
// embedded-engine arm (objectSeams.ts, already keyed off object.Kind).
55+
extension: {
56+
icon: FlowchartIcon,
57+
label: 'Diagram',
58+
description: 'View and edit diagrams — draw.io files open in the real editor.',
59+
disableScopeNote: 'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.',
60+
},
4861
})

frontend/src/atlas/tools/sheetNoun.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lazy } from 'react'
2+
import { ColumnsIcon } from '@primer/octicons-react'
23
import { registerBoardObjectContent } from '../atlasNounRegistry'
34

45
// Lazy-imported (React.lazy + Suspense, AtlasBoardObjectNode.tsx's own
@@ -40,4 +41,16 @@ registerBoardObjectContent('sheet', {
4041
// directly.
4142
source: { kind: 'file', pathKey: 'mirrorPath' },
4243
editRoute: { kind: 'external-app' },
44+
// extension (goal 0237 S3 rider): the Settings > Extensions row for
45+
// this tool-less noun. disableScopeNote states its scope honestly --
46+
// there is no tray button to hide and no embedded editor to close
47+
// (editRoute above is a static 'external-app', which dispatchObjectEdit
48+
// never gates), so the toggle only affects
49+
// useAtlasNativeFileDrop.ts's own routing.
50+
extension: {
51+
icon: ColumnsIcon,
52+
label: 'Sheet',
53+
description: 'Preview spreadsheets and CSV files dropped onto the board.',
54+
disableScopeNote: 'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.',
55+
},
4356
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveFileDropKind } from './useAtlasNativeFileDrop'
3+
4+
const alwaysEnabled = () => true
5+
const alwaysDisabled = () => false
6+
7+
describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
8+
it('routes a diagram path to "diagram" when the diagram extension is enabled', () => {
9+
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram')
10+
})
11+
12+
// Regression: disabling diagram from Settings > Extensions has no
13+
// tray button to remove (diagram is file-drop only), so it must
14+
// instead fall the drop through to the generic card path.
15+
it('falls a disabled diagram drop through to the "card" path', () => {
16+
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysDisabled)).toBe('card')
17+
expect(resolveFileDropKind('/tmp/flow.mmd', (id) => id !== 'diagram')).toBe('card')
18+
})
19+
20+
it('routes a sheet path to "sheet" when the sheet extension is enabled', () => {
21+
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled)).toBe('sheet')
22+
expect(resolveFileDropKind('/tmp/data.csv', alwaysEnabled)).toBe('sheet')
23+
})
24+
25+
// Regression: same fall-through as diagram -- sheet has no tray
26+
// button either.
27+
it('falls a disabled sheet drop through to the "card" path', () => {
28+
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysDisabled)).toBe('card')
29+
expect(resolveFileDropKind('/tmp/data.csv', (id) => id !== 'sheet')).toBe('card')
30+
})
31+
32+
it('routes an image path to "image" regardless of diagram/sheet enablement', () => {
33+
expect(resolveFileDropKind('/tmp/photo.png', alwaysDisabled)).toBe('image')
34+
})
35+
36+
it('falls an unrelated extension through to "card"', () => {
37+
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card')
38+
})
39+
})

0 commit comments

Comments
 (0)