diff --git a/internal/mcpapp/css/input.css b/internal/mcpapp/css/input.css index 003ec612..c7f0391a 100644 --- a/internal/mcpapp/css/input.css +++ b/internal/mcpapp/css/input.css @@ -107,6 +107,27 @@ body { @apply w-full px-2.5 py-2 border border-border rounded-md bg-background-deep text-foreground font-sans placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent; } +/* ---- File picker (MCP upload apps) --------------------------------------- */ +/* The native cannot be styled, so pin it invisible atop a + styled chrome (a "Choose file" button + the picked name). Clicking anywhere + on the field opens the native picker; the name is refreshed from JS on + change. */ +@utility file-field { + @apply relative cursor-pointer; +} +@utility file-input { + @apply absolute inset-0 w-full h-full opacity-0 cursor-pointer; +} +@utility file-picker { + @apply flex items-center gap-2 px-2.5 py-2 border border-border rounded-md bg-background-deep; +} +@utility file-btn { + @apply shrink-0 inline-flex items-center px-3 py-1 rounded border border-border bg-card text-foreground text-sm font-medium cursor-pointer select-none transition-colors hover:bg-card/80; +} +@utility file-name { + @apply truncate min-w-0 text-sm text-muted-foreground; +} + /* ---- Tables (MCP pin-list + vault) --------------------------------------- */ @utility table { @apply w-full text-sm text-foreground; diff --git a/internal/mcpapp/ipfs_upload_app.templ b/internal/mcpapp/ipfs_upload_app.templ index a4fa7937..12bea4a5 100644 --- a/internal/mcpapp/ipfs_upload_app.templ +++ b/internal/mcpapp/ipfs_upload_app.templ @@ -10,7 +10,13 @@ templ IPFSUploadAppForm() {

Upload to IPFS

- +
+ +
+ Choose file + No file chosen +
+
diff --git a/internal/mcpapp/vault_upload_app.templ b/internal/mcpapp/vault_upload_app.templ index 5379aaf5..fd90f586 100644 --- a/internal/mcpapp/vault_upload_app.templ +++ b/internal/mcpapp/vault_upload_app.templ @@ -10,7 +10,13 @@ templ VaultUploadAppForm() {

Upload to Vault

- +
+ +
+ Choose file + No file chosen +
+
diff --git a/packages/apps/src/entries/ipfs-upload.ts b/packages/apps/src/entries/ipfs-upload.ts index e3ef4cbf..42363063 100644 --- a/packages/apps/src/entries/ipfs-upload.ts +++ b/packages/apps/src/entries/ipfs-upload.ts @@ -26,6 +26,7 @@ export const def: IPFSUploadAppEntry = { ids: { form: "ipfs-upload-form", file: "file", + fileName: "file-name", name: "name", status: "ipfs-upload-status", outCid: "out-cid", diff --git a/packages/apps/src/entries/vault-upload.ts b/packages/apps/src/entries/vault-upload.ts index 8014cafe..08932cdd 100644 --- a/packages/apps/src/entries/vault-upload.ts +++ b/packages/apps/src/entries/vault-upload.ts @@ -22,6 +22,7 @@ export const def: VaultUploadAppEntry = { ids: { form: "vault-upload-form", file: "vfile", + fileName: "vfile-name", vaultPath: "vault-path", status: "vault-upload-status", outPath: "out-path", diff --git a/packages/apps/src/ipfs-upload-bootstrap.ts b/packages/apps/src/ipfs-upload-bootstrap.ts index 9828194c..8988256a 100644 --- a/packages/apps/src/ipfs-upload-bootstrap.ts +++ b/packages/apps/src/ipfs-upload-bootstrap.ts @@ -4,7 +4,8 @@ // // Element contract: // #ipfs-upload-form the that starts the upload. -// #file the file . +// #file the file (styled composite picker). +// #file-name the picked-file label span ("No file chosen"). // #name the upload-name . // #ipfs-upload-status the status element (class "status "). // #out-cid the result CID . @@ -38,6 +39,7 @@ import { byId, setStatus, StatusClass } from "@/dom"; export type IPFSUploadElementIds = { form: string; file: string; + fileName: string; name: string; status: string; outCid: string; @@ -49,7 +51,8 @@ export type IPFSUploadAppEntry = AppDefinition void): void }; - fileInput: { files: FileList | null }; + fileInput: HTMLInputElement; + fileNameEl: HTMLElement; nameInput: { value: string }; statusEl: HTMLElement; outCid: HTMLElement; @@ -135,6 +138,14 @@ export function runIPFSUploadEntry(opts: IPFSUploadEntryOptions) { if (r.setCid) opts.elements.outCid.textContent = ctx.outCid || opts.elements.outCid.textContent; }); + // Reflect the picked file's name in the styled picker chrome. The native + // input is invisible (see file-field/file-input in the theme), so without + // this the user has no way to see what was selected. + opts.elements.fileInput.addEventListener("change", () => { + const picked = opts.elements.fileInput.files?.[0]; + opts.elements.fileNameEl.textContent = picked ? picked.name : "No file chosen"; + }); + const submit = (file: File | null, name: string) => { if (!file) { setStatus(opts.elements.statusEl, StatusClass.Error, opts.config.noFileMsg); @@ -176,6 +187,7 @@ export function mountIPFSUploadApp(def: IPFSUploadAppEntry, root: Document, call elements: { form: byId(root, def.ids.form)!, fileInput: byId(root, def.ids.file)!, + fileNameEl: byId(root, def.ids.fileName)!, nameInput: byId(root, def.ids.name)!, statusEl: statusEl!, outCid: byId(root, def.ids.outCid)!, diff --git a/packages/apps/src/vault-upload-bootstrap.ts b/packages/apps/src/vault-upload-bootstrap.ts index 93c8875f..7338355a 100644 --- a/packages/apps/src/vault-upload-bootstrap.ts +++ b/packages/apps/src/vault-upload-bootstrap.ts @@ -5,7 +5,8 @@ // // Element contract: // #vault-upload-form the that submits the upload. -// #vfile the file . +// #vfile the file (styled composite picker). +// #vfile-name the picked-file label span ("No file chosen"). // #vault-path the vault destination path . // #vault-upload-status the status element (class "status "). // #out-path the result stored-path . @@ -31,6 +32,7 @@ import { byId, setStatus, StatusClass } from "@/dom"; export type VaultUploadElementIds = { form: string; file: string; + fileName: string; vaultPath: string; status: string; outPath: string; @@ -42,7 +44,8 @@ export type VaultUploadAppEntry = AppDefinition void): void }; - fileInput: { files: FileList | null }; + fileInput: HTMLInputElement; + fileNameEl: HTMLElement; vaultPathInput: { value: string }; statusEl: HTMLElement; outPath: HTMLElement; @@ -133,6 +136,14 @@ export function runVaultUploadEntry(opts: VaultUploadEntryOptions) { if (r.setOutPath && ctx.outPath) opts.elements.outPath.textContent = ctx.outPath; }); + // Reflect the picked file's name in the styled picker chrome. The native + // input is invisible (see file-field/file-input in the theme), so without + // this the user has no way to see what was selected. + opts.elements.fileInput.addEventListener("change", () => { + const picked = opts.elements.fileInput.files?.[0]; + opts.elements.fileNameEl.textContent = picked ? picked.name : "No file chosen"; + }); + const submit = async (file: File | null, vaultPath: string) => { if (!file) { setStatus(opts.elements.statusEl, StatusClass.Error, opts.config.noFileMsg); @@ -179,6 +190,7 @@ export function mountVaultUploadApp(def: VaultUploadAppEntry, root: Document, ca elements: { form: byId(root, def.ids.form)!, fileInput: byId(root, def.ids.file)!, + fileNameEl: byId(root, def.ids.fileName)!, vaultPathInput: byId(root, def.ids.vaultPath)!, statusEl: statusEl!, outPath: byId(root, def.ids.outPath)!, diff --git a/tests/sunpeak/tests/inspector-apps.test.ts b/tests/sunpeak/tests/inspector-apps.test.ts index 3b276216..1496ce9c 100644 --- a/tests/sunpeak/tests/inspector-apps.test.ts +++ b/tests/sunpeak/tests/inspector-apps.test.ts @@ -82,3 +82,43 @@ test('upload_file app boots (ipfs-upload bundle has no unmet imports)', async ({ test('vault_put_file app boots (vault-upload bundle has no unmet imports)', async ({ inspector }) => { await assertUploadAppBoots('vault_put_file', 'Upload to Vault', inspector); }); + +/** + * The upload forms use a styled composite file picker: the native + * is pinned invisible under a "Choose file" button + a + * picked-file label, because the browser's native "Choose file/No file chosen" + * chrome cannot be themed. Verify the chrome renders and that picking a file + * updates the label (the bootstraps wire a change listener for that). + */ +async function assertStyledFilePicker( + tool: string, + fileId: string, + labelId: string, + inspector: { renderTool: (n: string, i: unknown) => Promise<{ app(): FrameLocator }> }, +) { + const result = await inspector.renderTool(tool, {}); + const app = result.app(); + const body = await app.locator('body').innerText(); + expect(body).toContain('Choose file'); + expect(body).toContain('No file chosen'); + expect(body).toContain('Upload'); + + // Actually select a file through the hidden native input and assert the + // picker label adopts the filename (proves the change listener fired and + // the machine is wired, not just the static HTML shell). + const fileInput = app.locator(`#${fileId}`); + await fileInput.setInputFiles({ + name: 'report.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('pdf-bytes'), + }); + await expect(app.locator(`#${labelId}`)).toHaveText('report.pdf'); +} + +test('upload_file app renders the styled file picker and reflects the picked name', async ({ inspector }) => { + await assertStyledFilePicker('upload_file', 'file', 'file-name', inspector); +}); + +test('vault_put_file app renders the styled file picker and reflects the picked name', async ({ inspector }) => { + await assertStyledFilePicker('vault_put_file', 'vfile', 'vfile-name', inspector); +});