From ee45aa9726d1adbad56c123bde52341f8b903b44 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:40:13 -0500 Subject: [PATCH 01/10] A text= click lands on the thing you would have clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/test-e2e.sh has been exiting 1 on two Suite 2 assertions. Neither was the app and neither was quite the test. __resolve matches every element whose trimmed text equals the target, which for one sidebar row is the
  • , the +
    + + +
    {/if} {#if railError} diff --git a/web/src/hosted/app/app-shell.css b/web/src/hosted/app/app-shell.css index e2030a37..1813bdc5 100644 --- a/web/src/hosted/app/app-shell.css +++ b/web/src/hosted/app/app-shell.css @@ -1038,6 +1038,18 @@ line-height: 1.55; } +.download-only-intro .download-only-note { + margin-top: 0.6rem; + color: var(--rust); + font-size: 0.85rem; + font-style: italic; + line-height: 1.45; +} + +.download-only-note strong { + font-weight: 700; +} + .download-only-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1723,6 +1735,17 @@ transition: background-color 120ms ease, border-color 120ms ease; } +.hosted-sidebar-add-group { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + width: 100%; +} + +.hosted-sidebar-add-folder { + color: color-mix(in oklch, var(--sidebar-foreground) 82%, transparent); +} + .hosted-sidebar-add-label { font-weight: 550; } From 3f2da4b71d9d8211ff1c60fb78d68591394118cb Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:47:38 -0500 Subject: [PATCH 10/10] Fix hosted images, imports, and tree affordances --- .../fixtures/chooser-folder/chooser-folder.md | 3 + tests/fixtures/chooser-folder/deep/deep.md | 3 + web/hosted-share-smoke.md | 25 +++ web/scripts/test-hosted-local-share-ui.ts | 149 +++++++++++++++++- web/src/app.css | 30 +++- web/src/hosted/app/EditorShell.svelte | 74 +++++---- web/src/hosted/app/ImportChooser.svelte | 66 ++++++++ web/src/hosted/app/OpenPage.svelte | 26 ++- web/src/hosted/app/app-shell.css | 114 +++++++++++++- web/src/lib/FileTree.svelte | 5 +- web/src/lib/ReviewFileTree.svelte | 10 +- .../lib/review/external-image-policy.test.ts | 7 +- web/src/lib/shell.test.ts | 65 ++++++++ 13 files changed, 526 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/chooser-folder/chooser-folder.md create mode 100644 tests/fixtures/chooser-folder/deep/deep.md create mode 100644 web/hosted-share-smoke.md create mode 100644 web/src/hosted/app/ImportChooser.svelte diff --git a/tests/fixtures/chooser-folder/chooser-folder.md b/tests/fixtures/chooser-folder/chooser-folder.md new file mode 100644 index 00000000..ea939118 --- /dev/null +++ b/tests/fixtures/chooser-folder/chooser-folder.md @@ -0,0 +1,3 @@ +# Folder chooser fixture + +This file verifies that a directory picker preserves its relative path. diff --git a/tests/fixtures/chooser-folder/deep/deep.md b/tests/fixtures/chooser-folder/deep/deep.md new file mode 100644 index 00000000..6a5f1ee0 --- /dev/null +++ b/tests/fixtures/chooser-folder/deep/deep.md @@ -0,0 +1,3 @@ +# Nested folder chooser fixture + +This nested file verifies independent disclosure state. diff --git a/web/hosted-share-smoke.md b/web/hosted-share-smoke.md new file mode 100644 index 00000000..78429921 --- /dev/null +++ b/web/hosted-share-smoke.md @@ -0,0 +1,25 @@ +# Hosted image and import smoke test + +Run the app with `task dev:app`, then open `http://127.0.0.1:5173/open`. + +1. Choose **Files or folder** → **Folder**, select `tests/fixtures`, and open + `images.md` in the new workspace. The Pexels image should render for the + owner; `./gone.png` should remain a truthful missing-file placeholder. +2. Open the share control, create a review link, and visit it in a private + second browser context. The Pexels image remains blocked until **Load + external images** is chosen; after opting in it renders, and a reload resets + the choice. +3. In the sidebar, use the single **Add files** well. Its menu must offer + **Files** and **Folder**; folder imports retain nested paths. Expand and + collapse nested folders: closed chevrons point right, open chevrons point + down, and row hover/active fills stay inside the vertical folder guide. + +Use `/open` for a fresh import when an existing workspace already contains the +same path. Hosted workspaces deliberately reject duplicate paths rather than +silently overwriting durable content; the editor exposes an **Open fresh +import** link after that conflict. + +The deterministic lifecycle gate is `npm run test:share-ui:live` (or +`ATTN_SHARE_UI_EXTERNAL=1 npm run test:share-ui:live` with externally managed +local relay and Vite servers). It covers the exact Pexels URL, reviewer consent, +reload/offline durability, and the existing unsafe/missing-image fallbacks. diff --git a/web/scripts/test-hosted-local-share-ui.ts b/web/scripts/test-hosted-local-share-ui.ts index 90223d65..c0704889 100644 --- a/web/scripts/test-hosted-local-share-ui.ts +++ b/web/scripts/test-hosted-local-share-ui.ts @@ -32,6 +32,7 @@ const appUrl = `http://127.0.0.1:${appPort}`; const commentMarker = 'LOCAL-OWNER-REVIEW-COMMENT-9173'; const suggestionMarker = 'LOCAL-OWNER-REVIEW-SUGGESTION-9173'; const sharedImageSource = '../images/pixel.png'; +const workingRemoteImageSource = 'https://images.pexels.com/photos/35227957/pexels-photo-35227957.jpeg'; const remoteImageSource = 'https://images.attn.invalid/remote-share-image.png'; const unresolvedImageSource = 'data:;base64,'; const sharedPng = Buffer.from( @@ -178,6 +179,75 @@ async function expectBlockedRemoteImage(page: Page, label: string): Promise { + const image = page.locator(`.md-image[data-src="${workingRemoteImageSource}"] img`); + await image.waitFor({ state: 'attached', timeout: 60_000 }); + await image.waitFor({ state: 'hidden', timeout: 60_000 }); + const detail = await image.evaluate((element) => ({ + src: element.getAttribute('src'), + broken: element.parentElement?.getAttribute('data-broken'), + })); + if (detail.src !== unresolvedImageSource || detail.broken !== 'true') { + throw new Error(`${label} did not gate the approved remote image before opt-in: ${JSON.stringify(detail)}`); + } +} + +async function expectResolvedExternalImage(page: Page, label: string): Promise { + const wrapper = page.locator(`.md-image[data-src="${workingRemoteImageSource}"]`); + await wrapper.waitFor({ state: 'attached', timeout: 60_000 }); + await page.waitForFunction( + (source) => document.querySelector(`.md-image[data-src="${source}"]`)?.getAttribute('data-loaded') === 'true', + workingRemoteImageSource, + { timeout: 60_000 }, + ); + const image = wrapper.locator('img'); + const detail = await image.evaluate((element) => { + const imageElement = element as HTMLImageElement; + return { + src: imageElement.getAttribute('src'), + width: imageElement.naturalWidth, + height: imageElement.naturalHeight, + referrerPolicy: imageElement.getAttribute('referrerpolicy'), + loaded: imageElement.parentElement?.getAttribute('data-loaded'), + }; + }); + if ( + detail.loaded !== 'true' + || detail.width !== 1 + || detail.height !== 1 + || detail.src !== workingRemoteImageSource + || detail.referrerPolicy !== 'no-referrer' + ) { + throw new Error(`${label} did not render the approved remote image: ${JSON.stringify(detail)}`); + } +} + +async function chooseVisibleImport( + page: Page, + choice: 'Files' | 'Folder', + files: { name: string; mimeType: string; buffer: Buffer }[] | string, +): Promise { + await page.getByRole('button', { name: 'Add files', exact: true }).click(); + const menuItem = page.getByRole('menuitem', { name: new RegExp(`^${choice}\\b`, 'u') }); + await menuItem.waitFor({ state: 'visible' }); + const fileChooser = page.waitForEvent('filechooser'); + await menuItem.click(); + await (await fileChooser).setFiles(files); +} + +async function routeWorkingRemoteImage(context: BrowserContext): Promise { + // Keep the success assertion deterministic in CI while preserving the exact + // authored HTTPS URL in the DOM. A manual smoke run against the app itself + // still exercises the real Pexels response; this route only avoids making the + // lifecycle gate depend on a third-party CDN. + await context.route(`${workingRemoteImageSource}**`, (route) => route.fulfill({ + status: 200, + contentType: 'image/png', + body: sharedPng, + headers: { 'cache-control': 'no-store' }, + })); +} + async function expectAttemptedExternalImage(page: Page, label: string): Promise { const image = page.locator(`.md-image[data-src="${remoteImageSource}"] img`); await image.waitFor({ state: 'attached', timeout: 60_000 }); @@ -234,6 +304,18 @@ async function expectResolvedSharedHtmlImages( ); await expect(remote).toHaveJSProperty('naturalWidth', 0, { timeout: 60_000 }); await expect(remote).toHaveAttribute('referrerpolicy', 'no-referrer'); + const approvedRemote = frame.locator('#working-remote-html-image'); + await expect(approvedRemote).toHaveAttribute( + 'src', + externalImagesEnabled ? workingRemoteImageSource : unresolvedImageSource, + { timeout: 60_000 }, + ); + if (externalImagesEnabled) { + await expect(approvedRemote).toHaveJSProperty('naturalWidth', 1, { timeout: 60_000 }); + await expect(approvedRemote).toHaveJSProperty('naturalHeight', 1, { timeout: 60_000 }); + } else { + await expect(approvedRemote).toHaveJSProperty('naturalWidth', 0, { timeout: 60_000 }); + } const sandbox = await page.locator('[data-slot="html-viewer"] iframe').getAttribute('sandbox'); if (sandbox?.includes('allow-same-origin')) { throw new Error(`${label} weakened the opaque-origin HTML sandbox: ${sandbox}`); @@ -349,6 +431,7 @@ async function main(): Promise { browser = await chromium.launch({ headless: true }); ownerContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await routeWorkingRemoteImage(ownerContext); // The product correctly asks a first-time owner to choose a display name // after a room becomes active. This lifecycle gate is about durable review // convergence, so provide that ordinary prerequisite before navigation. @@ -509,7 +592,7 @@ async function main(): Promise { name: 'docs/review.md', mimeType: 'text/markdown', buffer: Buffer.from( - `# Hosted image share\n\n![Verified chart](${sharedImageSource})\n\n![Remote fallback](${remoteImageSource})\n\n`, + `# Hosted image share\n\n![Verified chart](${sharedImageSource})\n\n![Remote landscape](${workingRemoteImageSource})\n\n![Remote fallback](${remoteImageSource})\n\n`, ), }, { @@ -522,6 +605,7 @@ async function main(): Promise { Picture chart + Remote landscape Remote fallback `, ), @@ -530,7 +614,62 @@ async function main(): Promise { ]); await imageOwner.getByRole('button', { name: 'review.md', exact: true }).waitFor({ state: 'visible' }); await expectResolvedSharedImage(imageOwner, 'local owner'); + await expectResolvedExternalImage(imageOwner, 'local owner'); await expectAttemptedExternalImage(imageOwner, 'local owner'); + + // The visible rail affordance is one trigger with two explicit picker + // branches. Exercise both native inputs and retain a nested folder path so + // the tree geometry assertion below has a real branch to measure. + await chooseVisibleImport(imageOwner, 'Files', [{ + name: 'chooser-file.md', + mimeType: 'text/markdown', + buffer: Buffer.from('# Files chooser\n'), + }]); + try { + await imageOwner.locator('[data-path$="/chooser-file.md"]').waitFor({ state: 'visible', timeout: 60_000 }); + } catch (error) { + const debug = await imageOwner.evaluate(() => ({ + paths: [...document.querySelectorAll('[data-path]')].map((element) => element.getAttribute('data-path')), + rail: document.querySelector('.hosted-sidebar-error')?.textContent ?? null, + inputs: [...document.querySelectorAll('input[type="file"]')].map((element) => ({ + multiple: element.hasAttribute('multiple'), + directory: element.hasAttribute('webkitdirectory'), + files: (element as HTMLInputElement).files?.length ?? 0, + })), + })); + throw new Error(`files chooser import did not land: ${JSON.stringify(debug)}`, { cause: error }); + } + await chooseVisibleImport( + imageOwner, + 'Folder', + path.resolve(webRoot, '..', 'tests', 'fixtures', 'chooser-folder'), + ); + const nestedRow = imageOwner.locator('[data-path$="/chooser-folder/chooser-folder.md"]'); + await nestedRow.waitFor({ state: 'visible', timeout: 60_000 }); + const folderRow = imageOwner.locator('[data-path$="/chooser-folder"]'); + await folderRow.waitFor({ state: 'visible', timeout: 60_000 }); + const deepFolderRow = imageOwner.locator('[data-path$="/chooser-folder/deep"]'); + await deepFolderRow.waitFor({ state: 'visible', timeout: 60_000 }); + await expect(deepFolderRow).toHaveAttribute('aria-expanded', 'false'); + await expect(deepFolderRow.locator('.sidebar-tree-chevron')).not.toHaveClass(/sidebar-tree-chevron--open/u); + await expect(folderRow).toHaveAttribute('aria-expanded', 'true'); + await expect(folderRow.locator('.sidebar-tree-chevron')).toHaveClass(/sidebar-tree-chevron--open/u); + const nestedGeometry = await nestedRow.evaluate((element) => { + const style = getComputedStyle(element); + return { marginInlineStart: style.marginInlineStart, width: style.width }; + }); + if (nestedGeometry.marginInlineStart === '0px' || nestedGeometry.width === '100%') { + throw new Error(`nested file row was not inset from its folder guide: ${JSON.stringify(nestedGeometry)}`); + } + await folderRow.click(); + await expect(folderRow).toHaveAttribute('aria-expanded', 'false'); + await expect(folderRow.locator('.sidebar-tree-chevron')).not.toHaveClass(/sidebar-tree-chevron--open/u); + await folderRow.click(); + await expect(folderRow).toHaveAttribute('aria-expanded', 'true'); + await deepFolderRow.click(); + await expect(deepFolderRow).toHaveAttribute('aria-expanded', 'true'); + await expect(deepFolderRow.locator('.sidebar-tree-chevron')).toHaveClass(/sidebar-tree-chevron--open/u); + await expect(folderRow).toHaveAttribute('aria-expanded', 'true'); await imageOwner.getByRole('button', { name: 'preview.html', exact: true }).click(); await expectResolvedSharedHtmlImages(imageOwner, 'local owner HTML document', true); await imageOwner.getByRole('button', { name: 'review.md', exact: true }).click(); @@ -540,6 +679,7 @@ async function main(): Promise { const imageInvite = await createInvite(imageOwner, { selectAll: true }); reviewerContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await routeWorkingRemoteImage(reviewerContext); await reviewerContext.addInitScript(seedProfileDisplayName, { displayName: 'Image review agent' }); const imageReviewer = await reviewerContext.newPage(); captureBrowserFailures(imageReviewer, 'image reviewer'); @@ -548,10 +688,12 @@ async function main(): Promise { await imageReviewer.waitForFunction(() => document.querySelector('[data-slot="browser-review"]')?.getAttribute('data-authoring-ready') === 'true'); await imageReviewer.getByRole('button', { name: /review\.md/u }).click(); await expectResolvedSharedImage(imageReviewer, 'live invited reviewer'); + await expectBlockedApprovedImage(imageReviewer, 'live invited reviewer'); await expectBlockedRemoteImage(imageReviewer, 'live invited reviewer'); const loadExternalImages = imageReviewer.getByRole('button', { name: 'Load external images for this review' }); await loadExternalImages.waitFor({ state: 'visible' }); await loadExternalImages.click(); + await expectResolvedExternalImage(imageReviewer, 'opted-in invited reviewer'); await expectAttemptedExternalImage(imageReviewer, 'opted-in invited reviewer'); await imageReviewer.getByRole('button', { name: /preview\.html/u }).click(); await expectResolvedSharedHtmlImages(imageReviewer, 'opted-in invited reviewer HTML document', true); @@ -563,6 +705,7 @@ async function main(): Promise { await follower.locator('[data-slot="browser-review"]').waitFor({ state: 'visible' }); await follower.getByRole('button', { name: /review\.md/u }).click(); await expectResolvedSharedImage(follower, 'follower reviewer tab'); + await expectBlockedApprovedImage(follower, 'follower reviewer tab'); await expectBlockedRemoteImage(follower, 'follower reviewer tab'); await follower.close(); @@ -574,6 +717,7 @@ async function main(): Promise { await expectResolvedSharedHtmlImages(imageReviewer, 'reloaded invited reviewer HTML document'); await imageReviewer.getByRole('button', { name: /review\.md/u }).click(); await expectResolvedSharedImage(imageReviewer, 'reloaded invited reviewer'); + await expectBlockedApprovedImage(imageReviewer, 'reloaded invited reviewer'); await expectBlockedRemoteImage(imageReviewer, 'reloaded invited reviewer'); await reviewerContext.close(); reviewerContext = null; @@ -585,6 +729,7 @@ async function main(): Promise { await ownerContext.close(); ownerContext = null; offlineReviewerContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await routeWorkingRemoteImage(offlineReviewerContext); await offlineReviewerContext.addInitScript(seedProfileDisplayName, { displayName: 'Offline review agent' }); const offlineReviewer = await offlineReviewerContext.newPage(); captureBrowserFailures(offlineReviewer, 'offline reviewer'); @@ -597,11 +742,13 @@ async function main(): Promise { await expectResolvedSharedHtmlImages(offlineReviewer, 'owner-offline durable reviewer HTML document'); await offlineReviewer.getByRole('button', { name: /review\.md/u }).click(); await expectResolvedSharedImage(offlineReviewer, 'owner-offline durable reviewer'); + await expectBlockedApprovedImage(offlineReviewer, 'owner-offline durable reviewer'); await expectBlockedRemoteImage(offlineReviewer, 'owner-offline durable reviewer'); await offlineReviewer.reload({ waitUntil: 'domcontentloaded' }); await offlineReviewer.locator('[data-slot="browser-review"]').waitFor({ state: 'visible' }); await offlineReviewer.waitForFunction(() => document.querySelector('[data-slot="browser-review"]')?.getAttribute('data-owner-online') === 'false'); await expectResolvedSharedImage(offlineReviewer, 'reloaded owner-offline durable reviewer'); + await expectBlockedApprovedImage(offlineReviewer, 'reloaded owner-offline durable reviewer'); await expectBlockedRemoteImage(offlineReviewer, 'reloaded owner-offline durable reviewer'); step('hosted image share survived follower, reload, and owner-offline durable review'); diff --git a/web/src/app.css b/web/src/app.css index 20a80818..905e2d4e 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -286,8 +286,10 @@ ul.sidebar-tree-menu[data-sidebar] { min-height: 34px; max-height: 34px; margin-bottom: 2px; + margin-inline-start: calc(var(--tree-depth, 0) * 20px); + width: calc(100% - var(--tree-depth, 0) * 20px); padding-right: 10px; - padding-left: calc(var(--tree-depth, 0) * 20px + 10px); + padding-left: 10px; border: 1px solid transparent; border-radius: 8px; background: color-mix(in oklch, var(--foreground) 19%, transparent); @@ -923,8 +925,12 @@ ul.sidebar-tree-menu[data-sidebar] { min-height: 34px; max-height: 34px; margin-bottom: 2px; + /* Nested disclosure guides belong to the parent branch. Keep the row's + fill and active tick inside that guide instead of painting over it. */ + margin-inline-start: calc(var(--tree-depth, 0) * 20px); + width: calc(100% - var(--tree-depth, 0) * 20px); padding-right: 10px !important; - padding-left: calc(var(--tree-depth, 0) * 20px + 10px) !important; + padding-left: 10px !important; font-size: 0.875rem; line-height: 1; border: 1px solid transparent; @@ -1009,6 +1015,26 @@ ul.sidebar-tree-menu[data-sidebar] { .sidebar-tree-chevron { color: color-mix(in oklch, var(--sidebar-foreground) 72%, transparent); + transition: transform var(--t) var(--ease); + } + + .sidebar-tree-chevron--open { + transform: rotate(90deg); + } + + .review-tree-chevron { + transition: transform var(--t) var(--ease); + } + + .review-tree-chevron--open { + transform: rotate(90deg); + } + + @media (prefers-reduced-motion: reduce) { + .sidebar-tree-chevron, + .review-tree-chevron { + transition: none; + } } .sidebar-tree-icon { diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte index d3fdf6ce..b9c3b485 100644 --- a/web/src/hosted/app/EditorShell.svelte +++ b/web/src/hosted/app/EditorShell.svelte @@ -62,6 +62,7 @@ import { htmlImageSources, markdownImageSources } from '../../lib/review/document-image-sources'; import { isSupportedSharedImageMediaType } from '../../lib/review/shared-image-policy'; import LoadingLine from './LoadingLine.svelte'; + import ImportChooser from './ImportChooser.svelte'; import HtmlViewer from '../../lib/HtmlViewer.svelte'; import HtmlCommentComposer from '../../lib/HtmlCommentComposer.svelte'; import type { @@ -375,6 +376,7 @@ // no-op instead of renaming/deleting whichever file became active. let entryActionPath = $state(null); let railError = $state(null); + let importConflict = $state(false); let downloadingActiveEntry = $state(false); let activeEntryDownloadError = $state(null); let assetInput = $state(); @@ -1004,6 +1006,7 @@ const wsId = workspace.id; const onScreen = (): boolean => workspace.id === wsId; railError = null; + importConflict = false; try { await importIntoWorkspace({ workspaceId: wsId, @@ -1023,7 +1026,13 @@ } catch (error) { // The rail belongs to whatever is on screen now; an error about a // workspace the user has left would read as a failure of this one. - if (onScreen()) railError = error instanceof Error ? error.message : String(error); + if (onScreen()) { + const message = error instanceof Error ? error.message : String(error); + importConflict = /entry already exists/iu.test(message); + railError = importConflict + ? 'That path already exists in this workspace. Open a fresh import to use the current folder contents.' + : message; + } } finally { if (assetInput) assetInput.value = ''; } @@ -3468,10 +3477,10 @@ {/if} {/if} {#if showCanvasInvite} - +
    @@ -3485,12 +3494,12 @@ : 'Drop a Markdown file or a folder here, or choose one.'}

    - - + assetInput?.click()} + onChooseFolder={() => assetFolderInput?.click()} + />

    Files stay in this browser profile — nothing is uploaded. @@ -3697,34 +3706,20 @@ It is still one real - + assetInput?.click()} + onChooseFolder={() => assetFolderInput?.click()} + />

    {/if} {#if railError} + {#if importConflict} + Open fresh import + {/if} {/if}
    {/snippet} @@ -4123,9 +4118,12 @@ {/if} {/each} - + assetInput?.click()} + onChooseFolder={() => assetFolderInput?.click()} + /> {/if} diff --git a/web/src/hosted/app/ImportChooser.svelte b/web/src/hosted/app/ImportChooser.svelte new file mode 100644 index 00000000..5c3b6221 --- /dev/null +++ b/web/src/hosted/app/ImportChooser.svelte @@ -0,0 +1,66 @@ + + + + + {#if variant === 'sidebar'} + + {label} + {#if hint}{hint}{/if} + {:else} + {label} + {/if} + + + Bring in + choose(onChooseFiles)}> + + choose(onChooseFolder)}> + + + diff --git a/web/src/hosted/app/OpenPage.svelte b/web/src/hosted/app/OpenPage.svelte index f83632a4..399bb5f1 100644 --- a/web/src/hosted/app/OpenPage.svelte +++ b/web/src/hosted/app/OpenPage.svelte @@ -3,6 +3,7 @@ import { expandPicked, prepareImport } from './import-files'; import { fileDrop, filesToPicked, type DroppedFile } from './file-drop'; import type { ImportFileInput, StorageHealth } from './types'; + import ImportChooser from './ImportChooser.svelte'; interface Props { health: StorageHealth; @@ -12,6 +13,7 @@ const { health, onImport }: Props = $props(); let fileInput = $state(); + let folderInput = $state(); let importError = $state(null); async function importFiles(files: Iterable): Promise { @@ -30,6 +32,12 @@ const files = fileInput?.files; if (files && files.length > 0) void importFiles(Array.from(files)); } + + function onFolderPicked(): void { + const files = folderInput?.files; + if (files && files.length > 0) void importFiles(Array.from(files)); + if (folderInput) folderInput.value = ''; + }
    @@ -76,9 +84,12 @@
  • .zip
  • - + fileInput?.click()} + onChooseFolder={() => folderInput?.click()} + />
    + {#if importError}