From 3404164ee049c0bd5bb15d10e86db219d96f2a1f Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 22 Jun 2026 11:09:34 +0700 Subject: [PATCH 1/8] Add MiniFileChooser component (CS-11680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact, standalone file picker sized to match MiniCardChooser: a workspace dropdown over the indexed file tree, plus an Upload… button and drag-and-drop upload. Reuses RealmDropdown, IndexedFileTree, and the file-upload service. Fires onSelect with the picked or uploaded file URL; the hosting container owns confirmation. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/cs-11680-mini-file-chooser-plan.md | 83 ++++ .../components/file-chooser/mini/index.gts | 396 ++++++++++++++++++ .../components/file-chooser/mini/usage.gts | 73 ++++ .../host/app/templates/host-freestyle.gts | 2 + .../components/mini-file-chooser-test.gts | 296 +++++++++++++ 5 files changed, 850 insertions(+) create mode 100644 docs/cs-11680-mini-file-chooser-plan.md create mode 100644 packages/host/app/components/file-chooser/mini/index.gts create mode 100644 packages/host/app/components/file-chooser/mini/usage.gts create mode 100644 packages/host/tests/integration/components/mini-file-chooser-test.gts diff --git a/docs/cs-11680-mini-file-chooser-plan.md b/docs/cs-11680-mini-file-chooser-plan.md new file mode 100644 index 00000000000..a9981606674 --- /dev/null +++ b/docs/cs-11680-mini-file-chooser-plan.md @@ -0,0 +1,83 @@ +# CS-11680 — Mini File Chooser + +## Goal + +Build `MiniFileChooser`: a compact, standalone, independently-mountable file picker +sized to match `MiniCardChooser` (CS-11672). It is the second primitive in the +Markdown Editing UI sequence and is consumed by the combined modal in a later ticket. + +The design (Zeplin 09 / 09C) shows a **Workspace** dropdown, a **Choose File** label +over a bordered, scrollable file tree, and an **Upload…** button — plus a +drag-and-drop upload state. + +## File-embed serialization decision (documented deliverable) + +The ticket framed the choice as `:card[URL]` vs a plain markdown link `[name](URL)` +and said "do not add new BFM syntax." But BFM **already has a dedicated file syntax** — +inline `:file[URL]` and block `::file[URL]` — that resolves to `FileDef` instances and +is already rendered: + +- `packages/host/app/lib/codemirror-context.ts` — `BLOCK_FILE_RE` / `INLINE_FILE_RE`, `refType: 'card' | 'file'` +- `packages/host/app/components/operator-mode/preview-panel/rendered-markdown.gts` — `extractFileReferenceUrls`, `RenderSlot.refType === 'file'` + +**Decision: serialize file embeds as `:file[URL]` (block `::file[URL]`).** This is not +new syntax — it pre-exists and is the purpose-built path for files, strictly better +than `:card[URL]` (FileDef has dedicated file rendering) and better than a plain +`[name](URL)` link (renders as an anchor, not an embed). `MiniFileChooser` itself only +returns a URL via `onSelect`; downstream tickets apply the `:file[URL]` serialization. + +## Approach + +Mirror `MiniCardChooser`'s structure but back it with the file-tree path, not search. +Reuse — don't reimplement — the realm-dropdown, file-tree, and upload machinery +already proven in `ChooseFileModal`. + +### New files + +- `packages/host/app/components/file-chooser/mini/index.gts` +- `packages/host/app/components/file-chooser/mini/usage.gts` + +### Modified files + +- `packages/host/app/templates/host-freestyle.gts` — register `['MiniFileChooser', MiniFileChooserUsage]` + +### New test + +- `packages/host/tests/integration/components/mini-file-chooser-test.gts` + +### Signature + +```ts +Args: { + onSelect: (url: string) => void; // fired with selected/uploaded file URL + initialRealmURL?: string; // optional starting workspace (read once) + selected?: string; // optional pinned-selection URL +} +``` + +### Reused building blocks + +- `RealmDropdown` / `RealmDropdownItem` — `realm-dropdown.gts` +- `IndexedFileTree` — `editor/indexed-file-tree.gts` (`@realmURL`, `@onFileSelected`, `@onFileConfirmed`, `@selectedFile`, `@autoFocus`) +- `file-upload` service — `uploadFile`, `uploadProvidedFile`; `FileUploadTask`; `result: Promise` +- `realm.allRealmsInfo`, `RealmPaths(...).fileURL(path)`, `FileDef.sourceUrl` +- Drag-drop handlers lifted from `choose-file-modal.gts` + +### Behavior + +- Workspace dropdown defaults to `initialRealmURL` (or first known realm); switching it + recreates the tree (nonce+realm render key). +- Picking a file (click or Enter) resolves its URL via `RealmPaths(realm.url).fileURL(path)` + and calls `@onSelect`. No footer / Add / Cancel — the host owns confirmation. +- Upload (button → `uploadFile`, drag-drop → `uploadProvidedFile`); on completion the + resolved `FileDef.sourceUrl` is passed to `@onSelect`. Inline picking/uploading/error states. +- `@selected` highlights the matching tree row. +- Fluid 100%-of-parent flex column matching `MiniCardChooser`'s envelope. + +## Testing + +- `cd packages/host && pnpm lint` + `pnpm glint` clean. +- `mini-file-chooser-test.gts`: render in a 360×480 container, assert tree rows render, + clicking a file fires `onSelect` with the correct absolute URL, switching workspace + re-renders the tree, and `@selected` highlights a row. +- Manual: host-freestyle → MiniFileChooser usage. diff --git a/packages/host/app/components/file-chooser/mini/index.gts b/packages/host/app/components/file-chooser/mini/index.gts new file mode 100644 index 00000000000..fa432e34ad4 --- /dev/null +++ b/packages/host/app/components/file-chooser/mini/index.gts @@ -0,0 +1,396 @@ +import { array } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { BoxelButton, LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { RealmPaths, type LocalPath } from '@cardstack/runtime-common'; + +import type FileUploadService from '@cardstack/host/services/file-upload'; +import type { FileUploadTask } from '@cardstack/host/services/file-upload'; +import type RealmService from '@cardstack/host/services/realm'; + +import IndexedFileTree from '../../editor/indexed-file-tree'; +import RealmDropdown, { type RealmDropdownItem } from '../../realm-dropdown'; + +interface Signature { + Element: HTMLDivElement; + Args: { + // Fired with the absolute URL of the file the user picks from the tree or + // finishes uploading. The hosting container decides what to do with it + // (this primitive never confirms or dismisses on its own). + onSelect: (url: string) => void; + // Workspace to open on first render. Read once at mount; later parent + // updates are ignored. Defaults to the first known realm. + initialRealmURL?: string; + // Absolute URL of the currently selected file — the matching tree row gets + // the selection highlight. Omit for a chooser with no pinned selection. + selected?: string; + }; +} + +export default class MiniFileChooser extends Component { + @service declare private realm: RealmService; + @service('file-upload') declare private fileUpload: FileUploadService; + + @tracked private selectedRealm = this.initialRealm; + // The user's most recent in-tree pick, relative to the selected realm. Seeds + // the tree's highlight and takes precedence over @selected once the user acts. + @tracked private userSelectedFile?: LocalPath; + @tracked private currentUpload?: FileUploadTask; + @tracked private isDropZoneActive = false; + // Bumped on realm switch so IndexedFileTree (which keys off realm internally) + // is fully recreated rather than reused across workspaces. + @tracked private fileTreeRenderNonce = 0; + private dropZoneDragDepth = 0; + + private get knownRealms() { + return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ + url: new URL(entry[0]), + info: entry[1].info, + })); + } + + private get initialRealm() { + let realms = this.knownRealms; + let match = this.args.initialRealmURL + ? realms.find((r) => r.url.href === this.args.initialRealmURL) + : undefined; + return match ?? realms[0]; + } + + // Highlighted tree row: the user's own pick wins; otherwise derive a local + // path from @selected when it lives inside the open workspace. + private get selectedFile(): LocalPath | undefined { + if (this.userSelectedFile) { + return this.userSelectedFile; + } + let { selected } = this.args; + if (!selected || !this.selectedRealm) { + return undefined; + } + let paths = new RealmPaths(this.selectedRealm.url); + try { + let url = new URL(selected); + if (paths.inRealm(url)) { + return paths.local(url); + } + } catch { + // malformed URL or outside the realm — nothing to highlight + } + return undefined; + } + + private get fileTreeRenderKey(): string { + return `${this.fileTreeRenderNonce}:${this.selectedRealm.url.href}`; + } + + private get isUploadBusy(): boolean { + let state = this.currentUpload?.state; + return state === 'picking' || state === 'uploading'; + } + + private get dropZoneLabel() { + return `Drop file to upload to ${this.selectedRealm.info.name}`; + } + + @action + private selectRealm({ path }: RealmDropdownItem) { + let realm = this.knownRealms.find((r) => r.url.href === path); + if (realm) { + this.selectedRealm = realm; + this.userSelectedFile = undefined; + this.fileTreeRenderNonce++; + } + } + + @action + private selectFile(path: LocalPath) { + this.userSelectedFile = path; + let url = new RealmPaths(this.selectedRealm.url).fileURL(path); + this.args.onSelect(url.href); + } + + @action + private triggerUpload() { + let task = this.fileUpload.uploadFile({ + realmURL: this.selectedRealm.url, + }); + this.beginUpload(task); + } + + @action + private handleDragEnter(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth++; + this.isDropZoneActive = true; + } + + @action + private handleDragOver(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.isDropZoneActive = true; + if (dragEvent.dataTransfer) { + dragEvent.dataTransfer.dropEffect = 'copy'; + } + } + + @action + private handleDragLeave(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); + if (this.dropZoneDragDepth === 0) { + this.isDropZoneActive = false; + } + } + + @action + private handleDrop(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = 0; + this.isDropZoneActive = false; + if (this.isUploadBusy) { + return; + } + let file = dragEvent.dataTransfer?.files?.[0]; + if (!file) { + return; + } + let task = this.fileUpload.uploadProvidedFile({ + realmURL: this.selectedRealm.url, + file, + }); + this.beginUpload(task); + } + + private beginUpload(task: FileUploadTask) { + this.currentUpload = task; + task.result.then((fileDef) => { + if (fileDef?.sourceUrl) { + this.userSelectedFile = undefined; + this.currentUpload = undefined; + this.args.onSelect(fileDef.sourceUrl); + } else if (task.state !== 'error') { + this.currentUpload = undefined; + } + }); + } + + private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { + if (!dataTransfer) { + return false; + } + return Array.from(dataTransfer.types ?? []).includes('Files'); + } + + +} diff --git a/packages/host/app/components/file-chooser/mini/usage.gts b/packages/host/app/components/file-chooser/mini/usage.gts new file mode 100644 index 00000000000..f3c9c523878 --- /dev/null +++ b/packages/host/app/components/file-chooser/mini/usage.gts @@ -0,0 +1,73 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import MiniFileChooser from './index'; + +export default class MiniFileChooserUsage extends Component { + @tracked selectedUrl: string | undefined; + + @action onSelect(url: string) { + this.selectedUrl = url; + } + + +} diff --git a/packages/host/app/templates/host-freestyle.gts b/packages/host/app/templates/host-freestyle.gts index 9e63a71c57a..13c8e1f998a 100644 --- a/packages/host/app/templates/host-freestyle.gts +++ b/packages/host/app/templates/host-freestyle.gts @@ -24,6 +24,7 @@ import AiAssistantMessageUsage from '@cardstack/host/components/ai-assistant/mes import AiAssistantSkillMenuUsage from '@cardstack/host/components/ai-assistant/skill-menu/usage'; import MiniCardChooserUsage from '@cardstack/host/components/card-chooser/mini/usage'; import CardChooserModal from '@cardstack/host/components/card-chooser/modal'; +import MiniFileChooserUsage from '@cardstack/host/components/file-chooser/mini/usage'; import PillMenuUsage from '@cardstack/host/components/pill-menu/usage'; import SearchSheetUsage from '@cardstack/host/components/search-sheet/usage'; @@ -76,6 +77,7 @@ class HostFreestyleComponent extends Component { ['AiAssistant::PillMenu', PillMenuUsage], ['AiAssistant::SkillMenu', AiAssistantSkillMenuUsage], ['MiniCardChooser', MiniCardChooserUsage], + ['MiniFileChooser', MiniFileChooserUsage], ['SearchSheet', SearchSheetUsage], ].map(([name, c]) => { return { diff --git a/packages/host/tests/integration/components/mini-file-chooser-test.gts b/packages/host/tests/integration/components/mini-file-chooser-test.gts new file mode 100644 index 00000000000..01fa75d0247 --- /dev/null +++ b/packages/host/tests/integration/components/mini-file-chooser-test.gts @@ -0,0 +1,296 @@ +import type { TOC } from '@ember/component/template-only'; +import { + type RenderingTestContext, + click, + render, + triggerEvent, + waitFor, + waitUntil, +} from '@ember/test-helpers'; + +import { tracked } from '@glimmer/tracking'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { baseRealm } from '@cardstack/runtime-common'; + +import MiniFileChooser from '@cardstack/host/components/file-chooser/mini'; +import type FileUploadService from '@cardstack/host/services/file-upload'; + +import { + realmConfigCardJSON, + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, +} from '../../helpers'; +import { + CardDef, + StringField, + contains, + field, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +// Sized envelope mirroring the chooser's intended hosting context (a narrow +// side panel, ~360×480). The chooser's layout is fluid (100% of parent), so +// the bordered scroll box only constrains the tree at a realistic size. +const DesignRatioContainer: TOC<{ Blocks: { default: [] } }> = ; + +// Round-trips a selection the way a real hosting container does: onSelect feeds +// tracked state back into @selected so the picked row visibly highlights. +class SelectionHarness { + @tracked selected: string | undefined = undefined; + onSelect = (url: string) => { + this.selected = url; + }; +} + +// The file tree paints a loading mask over its rows for ~300ms after load. +// Wait it out so clicks land on the rows rather than the mask. +async function waitForFileTreeReady() { + await waitUntil(() => !document.querySelector('[data-test-file-tree-mask]'), { + timeout: 5000, + }); +} + +module('Integration | mini-file-chooser', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'realm.json': realmConfigCardJSON({ name: 'Test Workspace B' }), + 'pet.gts': { Pet }, + 'pets/mango.json': new Pet({ name: 'Mango' }), + 'pets/vangogh.json': new Pet({ name: 'Van Gogh' }), + 'notes/readme.txt': 'hello world', + }, + }); + await getService('realm').login(testRealmURL); + }); + + test('mounts in isolation with a workspace dropdown, file tree, and upload button', async function (assert) { + const harness = new SelectionHarness(); + + await render( + , + ); + + await waitFor('[data-test-mini-file-chooser] [data-test-file-tree-nav]'); + await waitForFileTreeReady(); + + assert + .dom('[data-test-mini-file-chooser]') + .exists('the mini file chooser mounts in isolation'); + assert + .dom('[data-test-mini-file-chooser-realm-chooser]') + .exists('the workspace dropdown is rendered'); + assert + .dom('[data-test-mini-file-chooser-upload-button]') + .hasText('Upload…', 'the upload button is rendered'); + + // Both files and directories from the realm appear in the tree. + assert + .dom('[data-test-mini-file-chooser] [data-test-file="pet.gts"]') + .exists('a root-level file renders in the tree'); + assert + .dom('[data-test-mini-file-chooser] [data-test-directory="pets/"]') + .exists('a directory renders in the tree'); + }); + + test('selecting a file fires onSelect with its absolute URL', async function (assert) { + const harness = new SelectionHarness(); + + await render( + , + ); + + await waitFor('[data-test-mini-file-chooser] [data-test-file="pet.gts"]'); + await waitForFileTreeReady(); + + await click('[data-test-mini-file-chooser] [data-test-file="pet.gts"]'); + await waitUntil(() => harness.selected === `${testRealmURL}pet.gts`); + + assert.strictEqual( + harness.selected, + `${testRealmURL}pet.gts`, + 'onSelect fires with the absolute file URL', + ); + // The selection round-trips through @selected to highlight the row. + assert + .dom('[data-test-mini-file-chooser] [data-test-file="pet.gts"]') + .hasClass('selected', 'the picked file row is highlighted'); + }); + + test('switching workspace re-renders the tree against the new realm', async function (assert) { + const harness = new SelectionHarness(); + + // Open against the base realm first, then switch to the test realm and + // confirm a test-realm-only file appears. + await render( + , + ); + + await waitFor('[data-test-mini-file-chooser] [data-test-file-tree-nav]'); + await waitForFileTreeReady(); + assert + .dom('[data-test-mini-file-chooser] [data-test-file="pet.gts"]') + .doesNotExist('test-realm file is absent while base realm is selected'); + + await click('[data-test-mini-file-chooser-realm-chooser]'); + await click(`[data-test-boxel-menu-item-text="Test Workspace B"]`); + + await waitFor('[data-test-mini-file-chooser] [data-test-file="pet.gts"]', { + timeout: 5000, + }); + assert + .dom('[data-test-mini-file-chooser] [data-test-file="pet.gts"]') + .exists('switching to the test realm re-renders the tree with its files'); + }); + + test('drag-and-drop announces the upload target and uploads the dropped file', async function (assert) { + const harness = new SelectionHarness(); + + await render( + , + ); + + await waitFor('[data-test-mini-file-chooser] [data-test-file-tree-nav]'); + await waitForFileTreeReady(); + + let droppedFile = new File(['dropped contents'], 'dropped.txt', { + type: 'text/plain', + }); + + await triggerEvent('[data-test-mini-file-chooser]', 'dragenter', { + dataTransfer: { types: ['Files'], files: [droppedFile] }, + }); + + assert + .dom('[data-test-mini-file-chooser]') + .hasAttribute('data-drop-zone-active', 'true'); + let dropZoneLabel = document + .querySelector('[data-test-mini-file-chooser]') + ?.getAttribute('data-drop-zone-label'); + assert.ok( + dropZoneLabel?.startsWith('Drop file to upload to '), + 'the drop-zone label announces the upload target', + ); + + await triggerEvent('[data-test-mini-file-chooser]', 'drop', { + dataTransfer: { types: ['Files'], files: [droppedFile] }, + }); + + await waitUntil(() => harness.selected === `${testRealmURL}dropped.txt`, { + timeout: 10000, + timeoutMessage: 'drop upload did not resolve to onSelect', + }); + assert.strictEqual( + harness.selected, + `${testRealmURL}dropped.txt`, + 'a dropped file uploads and fires onSelect with its URL', + ); + }); + + test('the upload button uploads a chosen file and fires onSelect', async function (assert) { + const harness = new SelectionHarness(); + + await render( + , + ); + + await waitFor('[data-test-mini-file-chooser-upload-button]'); + await waitForFileTreeReady(); + + await click('[data-test-mini-file-chooser-upload-button]'); + + let fileUpload = getService('file-upload') as FileUploadService; + await waitUntil(() => fileUpload.activeUploads.length > 0, { + timeout: 5000, + timeoutMessage: 'upload task was not created', + }); + fileUpload.activeUploads[0].__provideFileForTesting( + new File(['uploaded contents'], 'uploaded.txt', { type: 'text/plain' }), + ); + + await waitUntil(() => harness.selected === `${testRealmURL}uploaded.txt`, { + timeout: 10000, + timeoutMessage: 'button upload did not resolve to onSelect', + }); + assert.strictEqual( + harness.selected, + `${testRealmURL}uploaded.txt`, + 'choosing a file via the upload button fires onSelect with its URL', + ); + }); +}); From 860977ba638b92a6d26b67f08926f59b2e7c8ac8 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 22 Jun 2026 13:18:01 +0700 Subject: [PATCH 2/8] Fix MiniFileChooser: empty-realm guard and drop-zone attribute selector - Guard against no known realms (the Freestyle smoke check renders the usage with no realms loaded) so the component renders without dereferencing an undefined selectedRealm. - Glimmer renders a true boolean attribute with an empty value, so match the drop-zone overlay with a presence selector (as choose-file-modal does) instead of [data-drop-zone-active='true']. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/file-chooser/mini/index.gts | 42 ++++++++++++------- .../components/mini-file-chooser-test.gts | 2 +- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/host/app/components/file-chooser/mini/index.gts b/packages/host/app/components/file-chooser/mini/index.gts index fa432e34ad4..f31ebf7f3b1 100644 --- a/packages/host/app/components/file-chooser/mini/index.gts +++ b/packages/host/app/components/file-chooser/mini/index.gts @@ -85,8 +85,12 @@ export default class MiniFileChooser extends Component { return undefined; } + private get selectedRealmURL(): string | undefined { + return this.selectedRealm?.url.href; + } + private get fileTreeRenderKey(): string { - return `${this.fileTreeRenderNonce}:${this.selectedRealm.url.href}`; + return `${this.fileTreeRenderNonce}:${this.selectedRealm?.url.href ?? ''}`; } private get isUploadBusy(): boolean { @@ -95,6 +99,9 @@ export default class MiniFileChooser extends Component { } private get dropZoneLabel() { + if (!this.selectedRealm) { + return ''; + } return `Drop file to upload to ${this.selectedRealm.info.name}`; } @@ -117,6 +124,9 @@ export default class MiniFileChooser extends Component { @action private triggerUpload() { + if (!this.selectedRealm) { + return; + } let task = this.fileUpload.uploadFile({ realmURL: this.selectedRealm.url, }); @@ -173,7 +183,7 @@ export default class MiniFileChooser extends Component { dragEvent.stopPropagation(); this.dropZoneDragDepth = 0; this.isDropZoneActive = false; - if (this.isUploadBusy) { + if (this.isUploadBusy || !this.selectedRealm) { return; } let file = dragEvent.dataTransfer?.files?.[0]; @@ -223,7 +233,7 @@ export default class MiniFileChooser extends Component { Workspace {
Choose File
- {{! Force recreation when the realm changes }} - {{#each (array this.fileTreeRenderKey)}} - - {{/each}} + {{#if this.selectedRealm}} + {{! Force recreation when the realm changes }} + {{#each (array this.fileTreeRenderKey)}} + + {{/each}} + {{/if}}
@@ -369,7 +381,7 @@ export default class MiniFileChooser extends Component { } /* Drag-and-drop overlay: dim the chooser and surface the drop label, mirroring choose-file-modal's drop-zone treatment. */ - .mini-file-chooser[data-drop-zone-active='true']::before { + .mini-file-chooser[data-drop-zone-active]::before { content: ''; position: absolute; inset: 0; @@ -377,7 +389,7 @@ export default class MiniFileChooser extends Component { pointer-events: none; z-index: 2; } - .mini-file-chooser[data-drop-zone-active='true']::after { + .mini-file-chooser[data-drop-zone-active]::after { content: attr(data-drop-zone-label); position: absolute; inset: 0; diff --git a/packages/host/tests/integration/components/mini-file-chooser-test.gts b/packages/host/tests/integration/components/mini-file-chooser-test.gts index 01fa75d0247..870b16811dd 100644 --- a/packages/host/tests/integration/components/mini-file-chooser-test.gts +++ b/packages/host/tests/integration/components/mini-file-chooser-test.gts @@ -231,7 +231,7 @@ module('Integration | mini-file-chooser', function (hooks) { assert .dom('[data-test-mini-file-chooser]') - .hasAttribute('data-drop-zone-active', 'true'); + .hasAttribute('data-drop-zone-active'); let dropZoneLabel = document .querySelector('[data-test-mini-file-chooser]') ?.getAttribute('data-drop-zone-label'); From b7d93bba4206d243457de6abf3f01d63abb733aa Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 22 Jun 2026 16:15:24 +0700 Subject: [PATCH 3/8] Extract shared FileChooser panel; migrate choose-file modal onto it Pull the realm selection, file-tree recreation key, upload lifecycle, and drag-and-drop state machine out of the mini chooser and the operator-mode choose-file modal into a single renderless FileChooser provider (file-chooser/panel.gts) that yields curried RealmDropdown/FileTree components plus the shared state and actions. Rewrite mini/index.gts to consume the panel, and replace operator-mode/choose-file-modal.gts with file-chooser/modal.gts, preserving the _CARDSTACK_FILE_CHOOSER global, chooseFile() API, and all data-test-choose-file-modal* hooks so existing callers and tests are unaffected. The migrated modal now clears the staged file on workspace switch, so the Add button can no longer resolve a path against the wrong realm. Add an integration test covering the panel's initial-realm selection, realm-switch notification + recreation-key bump, and file-only drop-zone gating. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/cs-11680-mini-file-chooser-plan.md | 83 --- .../components/file-chooser/mini/index.gts | 334 ++++------ .../app/components/file-chooser/modal.gts | 480 +++++++++++++++ .../app/components/file-chooser/panel.gts | 263 ++++++++ .../operator-mode/choose-file-modal.gts | 574 ------------------ .../components/operator-mode/container.gts | 4 +- .../components/file-chooser-panel-test.gts | 268 ++++++++ 7 files changed, 1118 insertions(+), 888 deletions(-) delete mode 100644 docs/cs-11680-mini-file-chooser-plan.md create mode 100644 packages/host/app/components/file-chooser/modal.gts create mode 100644 packages/host/app/components/file-chooser/panel.gts delete mode 100644 packages/host/app/components/operator-mode/choose-file-modal.gts create mode 100644 packages/host/tests/integration/components/file-chooser-panel-test.gts diff --git a/docs/cs-11680-mini-file-chooser-plan.md b/docs/cs-11680-mini-file-chooser-plan.md deleted file mode 100644 index a9981606674..00000000000 --- a/docs/cs-11680-mini-file-chooser-plan.md +++ /dev/null @@ -1,83 +0,0 @@ -# CS-11680 — Mini File Chooser - -## Goal - -Build `MiniFileChooser`: a compact, standalone, independently-mountable file picker -sized to match `MiniCardChooser` (CS-11672). It is the second primitive in the -Markdown Editing UI sequence and is consumed by the combined modal in a later ticket. - -The design (Zeplin 09 / 09C) shows a **Workspace** dropdown, a **Choose File** label -over a bordered, scrollable file tree, and an **Upload…** button — plus a -drag-and-drop upload state. - -## File-embed serialization decision (documented deliverable) - -The ticket framed the choice as `:card[URL]` vs a plain markdown link `[name](URL)` -and said "do not add new BFM syntax." But BFM **already has a dedicated file syntax** — -inline `:file[URL]` and block `::file[URL]` — that resolves to `FileDef` instances and -is already rendered: - -- `packages/host/app/lib/codemirror-context.ts` — `BLOCK_FILE_RE` / `INLINE_FILE_RE`, `refType: 'card' | 'file'` -- `packages/host/app/components/operator-mode/preview-panel/rendered-markdown.gts` — `extractFileReferenceUrls`, `RenderSlot.refType === 'file'` - -**Decision: serialize file embeds as `:file[URL]` (block `::file[URL]`).** This is not -new syntax — it pre-exists and is the purpose-built path for files, strictly better -than `:card[URL]` (FileDef has dedicated file rendering) and better than a plain -`[name](URL)` link (renders as an anchor, not an embed). `MiniFileChooser` itself only -returns a URL via `onSelect`; downstream tickets apply the `:file[URL]` serialization. - -## Approach - -Mirror `MiniCardChooser`'s structure but back it with the file-tree path, not search. -Reuse — don't reimplement — the realm-dropdown, file-tree, and upload machinery -already proven in `ChooseFileModal`. - -### New files - -- `packages/host/app/components/file-chooser/mini/index.gts` -- `packages/host/app/components/file-chooser/mini/usage.gts` - -### Modified files - -- `packages/host/app/templates/host-freestyle.gts` — register `['MiniFileChooser', MiniFileChooserUsage]` - -### New test - -- `packages/host/tests/integration/components/mini-file-chooser-test.gts` - -### Signature - -```ts -Args: { - onSelect: (url: string) => void; // fired with selected/uploaded file URL - initialRealmURL?: string; // optional starting workspace (read once) - selected?: string; // optional pinned-selection URL -} -``` - -### Reused building blocks - -- `RealmDropdown` / `RealmDropdownItem` — `realm-dropdown.gts` -- `IndexedFileTree` — `editor/indexed-file-tree.gts` (`@realmURL`, `@onFileSelected`, `@onFileConfirmed`, `@selectedFile`, `@autoFocus`) -- `file-upload` service — `uploadFile`, `uploadProvidedFile`; `FileUploadTask`; `result: Promise` -- `realm.allRealmsInfo`, `RealmPaths(...).fileURL(path)`, `FileDef.sourceUrl` -- Drag-drop handlers lifted from `choose-file-modal.gts` - -### Behavior - -- Workspace dropdown defaults to `initialRealmURL` (or first known realm); switching it - recreates the tree (nonce+realm render key). -- Picking a file (click or Enter) resolves its URL via `RealmPaths(realm.url).fileURL(path)` - and calls `@onSelect`. No footer / Add / Cancel — the host owns confirmation. -- Upload (button → `uploadFile`, drag-drop → `uploadProvidedFile`); on completion the - resolved `FileDef.sourceUrl` is passed to `@onSelect`. Inline picking/uploading/error states. -- `@selected` highlights the matching tree row. -- Fluid 100%-of-parent flex column matching `MiniCardChooser`'s envelope. - -## Testing - -- `cd packages/host && pnpm lint` + `pnpm glint` clean. -- `mini-file-chooser-test.gts`: render in a 360×480 container, assert tree rows render, - clicking a file fires `onSelect` with the correct absolute URL, switching workspace - re-renders the tree, and `@selected` highlights a row. -- Manual: host-freestyle → MiniFileChooser usage. diff --git a/packages/host/app/components/file-chooser/mini/index.gts b/packages/host/app/components/file-chooser/mini/index.gts index f31ebf7f3b1..c42a4072f6f 100644 --- a/packages/host/app/components/file-chooser/mini/index.gts +++ b/packages/host/app/components/file-chooser/mini/index.gts @@ -1,7 +1,6 @@ -import { array } from '@ember/helper'; +import { array, fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { action } from '@ember/object'; -import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; @@ -10,12 +9,9 @@ import { eq } from '@cardstack/boxel-ui/helpers'; import { RealmPaths, type LocalPath } from '@cardstack/runtime-common'; -import type FileUploadService from '@cardstack/host/services/file-upload'; -import type { FileUploadTask } from '@cardstack/host/services/file-upload'; -import type RealmService from '@cardstack/host/services/realm'; +import type { FileDef } from 'https://cardstack.com/base/file-api'; -import IndexedFileTree from '../../editor/indexed-file-tree'; -import RealmDropdown, { type RealmDropdownItem } from '../../realm-dropdown'; +import FileChooser, { type FileChooserRealm } from '../panel'; interface Signature { Element: HTMLDivElement; @@ -34,46 +30,23 @@ interface Signature { } export default class MiniFileChooser extends Component { - @service declare private realm: RealmService; - @service('file-upload') declare private fileUpload: FileUploadService; - - @tracked private selectedRealm = this.initialRealm; // The user's most recent in-tree pick, relative to the selected realm. Seeds // the tree's highlight and takes precedence over @selected once the user acts. @tracked private userSelectedFile?: LocalPath; - @tracked private currentUpload?: FileUploadTask; - @tracked private isDropZoneActive = false; - // Bumped on realm switch so IndexedFileTree (which keys off realm internally) - // is fully recreated rather than reused across workspaces. - @tracked private fileTreeRenderNonce = 0; - private dropZoneDragDepth = 0; - - private get knownRealms() { - return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ - url: new URL(entry[0]), - info: entry[1].info, - })); - } - - private get initialRealm() { - let realms = this.knownRealms; - let match = this.args.initialRealmURL - ? realms.find((r) => r.url.href === this.args.initialRealmURL) - : undefined; - return match ?? realms[0]; - } // Highlighted tree row: the user's own pick wins; otherwise derive a local // path from @selected when it lives inside the open workspace. - private get selectedFile(): LocalPath | undefined { + private selectedFileFor = ( + selectedRealm: FileChooserRealm | undefined, + ): LocalPath | undefined => { if (this.userSelectedFile) { return this.userSelectedFile; } let { selected } = this.args; - if (!selected || !this.selectedRealm) { + if (!selected || !selectedRealm) { return undefined; } - let paths = new RealmPaths(this.selectedRealm.url); + let paths = new RealmPaths(selectedRealm.url); try { let url = new URL(selected); if (paths.inRealm(url)) { @@ -83,222 +56,126 @@ export default class MiniFileChooser extends Component { // malformed URL or outside the realm — nothing to highlight } return undefined; - } - - private get selectedRealmURL(): string | undefined { - return this.selectedRealm?.url.href; - } - - private get fileTreeRenderKey(): string { - return `${this.fileTreeRenderNonce}:${this.selectedRealm?.url.href ?? ''}`; - } - - private get isUploadBusy(): boolean { - let state = this.currentUpload?.state; - return state === 'picking' || state === 'uploading'; - } - - private get dropZoneLabel() { - if (!this.selectedRealm) { - return ''; - } - return `Drop file to upload to ${this.selectedRealm.info.name}`; - } - - @action - private selectRealm({ path }: RealmDropdownItem) { - let realm = this.knownRealms.find((r) => r.url.href === path); - if (realm) { - this.selectedRealm = realm; - this.userSelectedFile = undefined; - this.fileTreeRenderNonce++; - } - } - - @action - private selectFile(path: LocalPath) { - this.userSelectedFile = path; - let url = new RealmPaths(this.selectedRealm.url).fileURL(path); - this.args.onSelect(url.href); - } - - @action - private triggerUpload() { - if (!this.selectedRealm) { - return; - } - let task = this.fileUpload.uploadFile({ - realmURL: this.selectedRealm.url, - }); - this.beginUpload(task); - } - - @action - private handleDragEnter(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth++; - this.isDropZoneActive = true; - } + }; @action - private handleDragOver(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.isDropZoneActive = true; - if (dragEvent.dataTransfer) { - dragEvent.dataTransfer.dropEffect = 'copy'; - } + private handleRealmChange() { + this.userSelectedFile = undefined; } @action - private handleDragLeave(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { + private handleFileSelected( + realm: FileChooserRealm | undefined, + path: LocalPath, + ) { + if (!realm) { return; } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); - if (this.dropZoneDragDepth === 0) { - this.isDropZoneActive = false; - } + this.userSelectedFile = path; + let url = new RealmPaths(realm.url).fileURL(path); + this.args.onSelect(url.href); } @action - private handleDrop(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = 0; - this.isDropZoneActive = false; - if (this.isUploadBusy || !this.selectedRealm) { - return; - } - let file = dragEvent.dataTransfer?.files?.[0]; - if (!file) { - return; - } - let task = this.fileUpload.uploadProvidedFile({ - realmURL: this.selectedRealm.url, - file, - }); - this.beginUpload(task); - } - - private beginUpload(task: FileUploadTask) { - this.currentUpload = task; - task.result.then((fileDef) => { - if (fileDef?.sourceUrl) { - this.userSelectedFile = undefined; - this.currentUpload = undefined; - this.args.onSelect(fileDef.sourceUrl); - } else if (task.state !== 'error') { - this.currentUpload = undefined; - } - }); - } - - private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { - if (!dataTransfer) { - return false; + private handleUploadComplete(fileDef: FileDef) { + if (fileDef.sourceUrl) { + this.userSelectedFile = undefined; + this.args.onSelect(fileDef.sourceUrl); } - return Array.from(dataTransfer.types ?? []).includes('Files'); } +} diff --git a/packages/host/app/components/file-chooser/panel.gts b/packages/host/app/components/file-chooser/panel.gts new file mode 100644 index 00000000000..ea832d4ef67 --- /dev/null +++ b/packages/host/app/components/file-chooser/panel.gts @@ -0,0 +1,263 @@ +import { hash } from '@ember/helper'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import type { CodeRef, LocalPath } from '@cardstack/runtime-common'; + +import type FileUploadService from '@cardstack/host/services/file-upload'; +import type { FileUploadTask } from '@cardstack/host/services/file-upload'; +import type RealmService from '@cardstack/host/services/realm'; +import type { EnhancedRealmInfo } from '@cardstack/host/services/realm'; + +import type { FileDef } from 'https://cardstack.com/base/file-api'; + +import IndexedFileTree from '../editor/indexed-file-tree'; +import RealmDropdown, { type RealmDropdownItem } from '../realm-dropdown'; + +import type { WithBoundArgs } from '@glint/template'; + +export type FileChooserRealm = { url: URL; info: EnhancedRealmInfo }; + +interface Signature { + Args: { + initialRealmURL?: string; + selectedFile?: LocalPath; + fileTypeFilter?: CodeRef; + fileFieldFilter?: Record; + acceptTypes?: string; + onRealmChange?: (realm: FileChooserRealm) => void; + onFileSelected?: (path: LocalPath) => void; + onFileConfirmed?: (path: LocalPath) => void; + onUploadComplete: (file: FileDef) => void; + }; + Blocks: { + default: [ + { + RealmDropdown: WithBoundArgs< + typeof RealmDropdown, + 'selectedRealmURL' | 'onSelect' | 'displayReadOnlyTag' + >; + FileTree: WithBoundArgs< + typeof IndexedFileTree, + | 'selectedFile' + | 'fileTypeFilter' + | 'fileFieldFilter' + | 'onFileSelected' + | 'onFileConfirmed' + >; + fileTreeKey: string; + selectedRealm: FileChooserRealm | undefined; + selectedRealmURL: string | undefined; + currentUpload: FileUploadTask | undefined; + isUploadBusy: boolean; + triggerUpload: () => void; + dropZoneActive: boolean; + dropZoneLabel: string; + onDragEnter: (event: Event) => void; + onDragOver: (event: Event) => void; + onDragLeave: (event: Event) => void; + onDrop: (event: Event) => void; + }, + ]; + }; +} + +export default class FileChooser extends Component { + @service declare private realm: RealmService; + @service('file-upload') declare private fileUpload: FileUploadService; + + @tracked private selectedRealm: FileChooserRealm | undefined = + this.initialRealm; + @tracked private currentUpload?: FileUploadTask; + @tracked private isDropZoneActive = false; + // Bumped on realm switch so the yielded FileTree (which keys off realm URL + // internally) is fully recreated rather than reused across workspaces. + @tracked private fileTreeRenderNonce = 0; + private dropZoneDragDepth = 0; + + private get knownRealms(): FileChooserRealm[] { + return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ + url: new URL(entry[0]), + info: entry[1].info, + })); + } + + private get initialRealm(): FileChooserRealm | undefined { + let realms = this.knownRealms; + let match = this.args.initialRealmURL + ? realms.find((r) => r.url.href === this.args.initialRealmURL) + : undefined; + return match ?? realms[0]; + } + + private get selectedRealmURL(): string | undefined { + return this.selectedRealm?.url.href; + } + + private get fileTreeKey(): string { + return `${this.fileTreeRenderNonce}:${this.selectedRealm?.url.href ?? ''}`; + } + + private get isUploadBusy(): boolean { + let state = this.currentUpload?.state; + return state === 'picking' || state === 'uploading'; + } + + private get dropZoneLabel(): string { + if (!this.selectedRealm) { + return ''; + } + return `Drop file to upload to ${this.selectedRealm.info.name}`; + } + + @action + private selectRealm({ path }: RealmDropdownItem) { + let realm = this.knownRealms.find((r) => r.url.href === path); + if (!realm) { + return; + } + this.selectedRealm = realm; + this.fileTreeRenderNonce++; + this.args.onRealmChange?.(realm); + } + + @action + private handleFileSelected(path: LocalPath) { + this.args.onFileSelected?.(path); + } + + @action + private handleFileConfirmed(path: LocalPath) { + this.args.onFileConfirmed?.(path); + } + + @action + private triggerUpload() { + if (!this.selectedRealm) { + return; + } + let task = this.fileUpload.uploadFile({ + realmURL: this.selectedRealm.url, + acceptTypes: this.args.acceptTypes, + }); + this.beginUpload(task); + } + + @action + private handleDragEnter(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth++; + this.isDropZoneActive = true; + } + + @action + private handleDragOver(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.isDropZoneActive = true; + if (dragEvent.dataTransfer) { + dragEvent.dataTransfer.dropEffect = 'copy'; + } + } + + @action + private handleDragLeave(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); + if (this.dropZoneDragDepth === 0) { + this.isDropZoneActive = false; + } + } + + @action + private handleDrop(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = 0; + this.isDropZoneActive = false; + if (this.isUploadBusy || !this.selectedRealm) { + return; + } + let file = dragEvent.dataTransfer?.files?.[0]; + if (!file) { + return; + } + let task = this.fileUpload.uploadProvidedFile({ + realmURL: this.selectedRealm.url, + file, + }); + this.beginUpload(task); + } + + private beginUpload(task: FileUploadTask) { + this.currentUpload = task; + task.result.then((fileDef) => { + if (fileDef) { + this.currentUpload = undefined; + this.args.onUploadComplete(fileDef); + } else if (task.state !== 'error') { + this.currentUpload = undefined; + } + }); + } + + private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { + if (!dataTransfer) { + return false; + } + return Array.from(dataTransfer.types ?? []).includes('Files'); + } + + +} diff --git a/packages/host/app/components/operator-mode/choose-file-modal.gts b/packages/host/app/components/operator-mode/choose-file-modal.gts deleted file mode 100644 index d328c7f8044..00000000000 --- a/packages/host/app/components/operator-mode/choose-file-modal.gts +++ /dev/null @@ -1,574 +0,0 @@ -import { registerDestructor } from '@ember/destroyable'; -import { array, fn } from '@ember/helper'; -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import type Owner from '@ember/owner'; -import { service } from '@ember/service'; -import Component from '@glimmer/component'; - -import { tracked } from '@glimmer/tracking'; - -import { task } from 'ember-concurrency'; -import perform from 'ember-concurrency/helpers/perform'; -import onKeyMod from 'ember-keyboard/modifiers/on-key'; - -import { - BoxelButton, - FieldContainer, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; - -import { eq } from '@cardstack/boxel-ui/helpers'; - -import { - Deferred, - RealmPaths, - isCardErrorJSONAPI, - loadCardDef, - type CodeRef, - type LocalPath, -} from '@cardstack/runtime-common'; - -import ModalContainer from '@cardstack/host/components/modal-container'; - -import type FileUploadService from '@cardstack/host/services/file-upload'; -import type { FileUploadTask } from '@cardstack/host/services/file-upload'; -import type LoaderService from '@cardstack/host/services/loader-service'; -import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; -import type RealmService from '@cardstack/host/services/realm'; -import type StoreService from '@cardstack/host/services/store'; - -import type { FileDef } from 'https://cardstack.com/base/file-api'; - -import IndexedFileTree from '../editor/indexed-file-tree'; -import RealmDropdown, { type RealmDropdownItem } from '../realm-dropdown'; - -interface Signature { - Args: {}; -} - -export default class ChooseFileModal extends Component { - @tracked deferred?: Deferred; - @tracked selectedRealm = this.knownRealms[0]; - @tracked selectedFile?: LocalPath; - @tracked fileTypeFilter?: CodeRef; - @tracked fileFieldFilter?: Record; - @tracked fileTypeName?: string; - @tracked acceptTypes?: string; - @tracked currentUpload?: FileUploadTask; - @tracked isDropZoneActive = false; - @tracked private fileTreeRenderNonce = 0; - private dropZoneDragDepth = 0; - - @service declare private operatorModeStateService: OperatorModeStateService; - @service declare private realm: RealmService; - @service declare private store: StoreService; - @service('file-upload') declare private fileUpload: FileUploadService; - @service('loader-service') declare private loaderService: LoaderService; - - constructor(owner: Owner, args: Signature['Args']) { - super(owner, args); - (globalThis as any)._CARDSTACK_FILE_CHOOSER = this; - registerDestructor(this, () => { - delete (globalThis as any)._CARDSTACK_FILE_CHOOSER; - }); - } - - private get modalTitle(): string { - if (this.fileTypeName) { - return `Choose ${this.fileTypeName}`; - } - return 'Choose a File'; - } - - private get isUploadBusy(): boolean { - let state = this.currentUpload?.state; - return state === 'picking' || state === 'uploading'; - } - - // public API - async chooseFile(opts?: { - fileType?: CodeRef; - fileTypeName?: string; - fileFieldFilter?: Record; - }): Promise { - this.deferred = new Deferred(); - this.fileTypeFilter = opts?.fileType; - this.fileFieldFilter = opts?.fileFieldFilter; - this.fileTypeName = opts?.fileTypeName; - this.acceptTypes = undefined; - this.currentUpload = undefined; - let defaultRealm = this.knownRealms.find( - (r) => - r.url.toString() === this.operatorModeStateService.realmURL?.toString(), - ); - this.selectedRealm = defaultRealm ?? this.selectedRealm; - this.fileTreeRenderNonce++; - - if (opts?.fileType) { - try { - let cardDef = await loadCardDef(opts.fileType, { - loader: this.loaderService.loader, - }); - this.acceptTypes = (cardDef as any).acceptTypes; - } catch { - // If we can't load the def, acceptTypes stays undefined (allow all) - } - } - - let file = await this.deferred.promise; - if (file) { - return file as T; - } else { - return undefined; - } - } - - private pickTask = task(async (path: LocalPath | undefined) => { - let deferred = this.deferred; - try { - if (deferred && this.selectedRealm && path) { - let fileURL = new RealmPaths(this.selectedRealm.url).fileURL(path); - let file = await this.store.get(fileURL.href, { - type: 'file-meta', - }); - if (isCardErrorJSONAPI(file)) { - deferred.reject( - new Error( - `choose-file-modal: failed to load file meta for ${fileURL.href}`, - ), - ); - return; - } - deferred.fulfill(file); - } else { - // Cancel / Escape / close with no selection: settle the promise with - // undefined so callers awaiting chooseFile() resume. Otherwise the - // deferred is dropped unsettled by resetState() and the await hangs - // forever — leaving a trigger button stuck disabled/loading. - deferred?.fulfill(undefined); - } - } finally { - this.resetState(); - } - }); - - @action - private triggerUpload() { - let task = this.fileUpload.uploadFile({ - realmURL: this.selectedRealm.url, - acceptTypes: this.acceptTypes, - }); - this.beginUpload(task); - } - - @action - private handleDragEnter(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth++; - this.isDropZoneActive = true; - } - - @action - private handleDragOver(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.isDropZoneActive = true; - if (dragEvent.dataTransfer) { - dragEvent.dataTransfer.dropEffect = 'copy'; - } - } - - @action - private handleDragLeave(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); - if (this.dropZoneDragDepth === 0) { - this.isDropZoneActive = false; - } - } - - @action - private handleDrop(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = 0; - this.isDropZoneActive = false; - if (this.isUploadBusy) { - return; - } - let file = dragEvent.dataTransfer?.files?.[0]; - if (!file) { - return; - } - let task = this.fileUpload.uploadProvidedFile({ - realmURL: this.selectedRealm.url, - file, - }); - this.beginUpload(task); - } - - private beginUpload(task: FileUploadTask) { - this.currentUpload = task; - task.result.then((fileDef) => { - if (fileDef && this.deferred) { - this.deferred.fulfill(fileDef); - this.resetState(); - } else if (task.state !== 'error') { - this.currentUpload = undefined; - } - }); - } - - private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { - if (!dataTransfer) { - return false; - } - return Array.from(dataTransfer.types ?? []).includes('Files'); - } - - private get dropZoneLabel() { - return `Drop file to upload to ${this.selectedRealm.info.name}`; - } - - private get fileTreeRenderKey(): string { - return `${this.fileTreeRenderNonce}:${this.selectedRealm.url.href}`; - } - - private resetState() { - this.selectedRealm = this.knownRealms[0]; - this.selectedFile = undefined; - this.fileTypeFilter = undefined; - this.fileFieldFilter = undefined; - this.fileTypeName = undefined; - this.acceptTypes = undefined; - this.currentUpload = undefined; - this.isDropZoneActive = false; - this.dropZoneDragDepth = 0; - this.deferred = undefined; - } - - private get knownRealms() { - return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ - url: new URL(entry[0]), - info: entry[1].info, - })); - } - - @action - private selectRealm({ path }: RealmDropdownItem) { - let realm = this.knownRealms.find((r) => r.url.href === path); - if (realm) { - this.selectedRealm = realm; - } - } - - @action - private selectFile(file: LocalPath) { - this.selectedFile = file; - } - - @action private handleKeydown(event: Event) { - let kbEvent = event as KeyboardEvent; - if (kbEvent.key === 'Escape') { - this.pickTask.perform(undefined); - return; - } - if (kbEvent.key === 'Tab') { - this.trapFocus(kbEvent); - } - } - - private trapFocus(event: KeyboardEvent) { - const container = event.currentTarget as HTMLElement; - const focusableSelector = [ - 'button:not([disabled]):not([tabindex="-1"])', - '[tabindex="0"]', - 'input:not([disabled])', - 'select:not([disabled])', - 'a[href]', - ].join(', '); - const focusable = Array.from( - container.querySelectorAll(focusableSelector), - ); - if (focusable.length < 2) return; - const first = focusable[0]!; - const last = focusable[focusable.length - 1]!; - - if (event.shiftKey) { - if (document.activeElement === first) { - event.preventDefault(); - last.focus(); - } - } else { - if (document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - } - } - - -} diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index 67aa2d72a52..5ca68af2f03 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -38,10 +38,10 @@ import type { CardContext } from 'https://cardstack.com/base/card-api'; import CardChooserModal from '../card-chooser/modal'; import SearchResults from '../card-search/search-results'; +import FileChooserModal from '../file-chooser/modal'; import PrerenderedCardSearch from '../prerendered-card-search'; import { Submodes } from '../submode-switcher'; -import ChooseFileModal from './choose-file-modal'; import CreateListingModal from './create-listing-modal'; import type CardService from '../../services/card-service'; @@ -146,7 +146,7 @@ export default class OperatorModeContainer extends Component {