Add MiniFileChooser component (CS-11680)#5298
Conversation
Preview deploymentsHost Test Results 1 files ± 0 1 suites ±0 2h 1m 43s ⏱️ + 1m 45s Results for commit fb177eb. ± Comparison against earlier commit b8226b9. Realm Server Test Results 1 files ±0 1 suites ±0 12m 7s ⏱️ + 1m 45s Results for commit fb177eb. ± Comparison against earlier commit b8226b9. For more details on these errors, see this check. |
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…tion The freestyle smoke check rendered MiniFileChooser — the first freestyle usage to mount a RealmDropdown — during app boot. RealmDropdown.selectedRealm fell back to realm.defaultWritableRealm, which reads matrixService.userName and throws "cannot use matrix client before matrix SDK has loaded" while the realm list is still loading. Short-circuit selectedRealm to undefined when the realm list is empty; the fallback resolved to undefined against an empty list anyway, so this is behavior-preserving. Also fix the file-chooser panel test: a bound boolean true renders as an empty-valued attribute, so assert data-drop-zone-active="" (the CSS uses it as a presence flag) rather than "true". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b83539a to
cfa552c
Compare
Clicking a file in the tree moved focus into the nav, and the :focus-within rule drew a teal outline around the whole tree box on every mouse pick. Gate the ring on :has(:focus-visible) so it appears for keyboard navigation only, not on click. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b20815bf6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @onFileConfirmed={{fn | ||
| this.handleFileSelected | ||
| chooser.selectedRealm | ||
| }} |
There was a problem hiding this comment.
Avoid firing onSelect twice for Enter
When the file tree has focus and the user presses Enter on a file, IndexedFileTree.handleKeydown calls selectFile() and then onFileConfirmed. Because the mini chooser wires both callbacks to handleFileSelected here, one keyboard activation invokes @onSelect twice, which can duplicate whatever the host does with that URL, such as inserting two file embeds.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. MiniFileChooser now routes @onFileSelected to a handleFileSelectOnly action that only updates the highlighted row, and routes @onFileConfirmed to handleFileSelected which is the sole caller of onSelect. Click highlights; Enter confirms and fires onSelect once. The integration test was updated to click-then-Enter.
| @tracked private selectedRealm: FileChooserRealm | undefined = | ||
| this.initialRealm; |
There was a problem hiding this comment.
Update selected realm when realms arrive
If this panel mounts before realm.allRealmsInfo has been populated, initialRealm is undefined and this tracked field stays undefined permanently; later realm-service updates do not rerun the initializer. In that boot/loading scenario the yielded chooser.selectedRealm remains empty, so consumers never render the file tree or have a realm to upload into until the component is recreated.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. selectedRealm is now a getter returning _explicitlySelectedRealm ?? initialRealm. initialRealm reads knownRealms (which depends on realm.allRealmsInfo), so a panel that mounted before the realm list populated picks up a realm reactively as soon as it loads — selectedRealmURL, the yielded tree, upload, drop, and the drop-zone label all follow the same getter.
| @onFileConfirmed={{fn | ||
| this.handleFileSelected | ||
| chooser.selectedRealm | ||
| }} | ||
| @autoFocus={{true}} |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. MiniFileChooser now wires @onFileSelected to handleFileSelectOnly (highlight-only, no onSelect) and @onFileConfirmed to handleFileSelected (the only path that fires onSelect). Enter no longer double-fires.
| private get selectedRealmURL(): string | undefined { | ||
| return this.selectedRealm?.url.href; | ||
| } | ||
|
|
||
| private get fileTreeKey(): string { | ||
| return `${this.fileTreeRenderNonce}:${this.selectedRealm?.url.href ?? ''}`; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. selectedRealm is now a getter (_explicitlySelectedRealm ?? initialRealm) — equivalent to the effective-realm fallback you suggested, applied centrally so every consumer (yielded tree, upload, drop, drop-zone label, selectedRealmURL) tracks the realm list as it loads.
| private get dropZoneLabel(): string { | ||
| if (!this.selectedRealm) { | ||
| return ''; | ||
| } | ||
| return `Drop file to upload to ${this.selectedRealm.info.name}`; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. dropZoneLabel reads this.selectedRealm, which is now the effective-realm getter, so the label fills in once the realm list loads.
| @action | ||
| private triggerUpload() { | ||
| if (!this.selectedRealm) { | ||
| return; | ||
| } | ||
| let task = this.fileUpload.uploadFile({ | ||
| realmURL: this.selectedRealm.url, | ||
| acceptTypes: this.args.acceptTypes, | ||
| }); | ||
| this.beginUpload(task); | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. triggerUpload reads this.selectedRealm via the new getter, so the button no longer becomes a no-op when realms arrive after mount.
| 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); |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. handleDrop reads this.selectedRealm via the new getter, so drops are honored as soon as the effective realm is available.
| fileTreeKey=this.fileTreeKey | ||
| selectedRealm=this.selectedRealm | ||
| selectedRealmURL=this.selectedRealmURL | ||
| currentUpload=this.currentUpload |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. The yielded selectedRealm is the getter (_explicitlySelectedRealm ?? initialRealm), so MiniFileChooser / FileChooserModal see the effective realm and render the tree once realms load.
| get selectedRealm(): RealmDropdownItem | undefined { | ||
| // Until the realm list has loaded there is nothing to select, and the | ||
| // defaultWritableRealm fallback below reaches into the matrix client — | ||
| // which throws if it's consulted before the matrix SDK is ready (e.g. while | ||
| // this dropdown renders during app boot). The fallback would resolve to | ||
| // undefined against an empty list anyway, so short-circuit. | ||
| if (this.realms.length === 0) { | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 20f6f99. selectedItemText now short-circuits to \' when selectedRealm is undefined, so the title and label stay empty during boot instead of rendering "In undefined".
…ter, realm-dropdown guard - panel.gts: Replace snapshot-initialized selectedRealm with getter/setter over _explicitlySelectedRealm, falling through to initialRealm reactively. Fixes Issues 2-6 where selectedRealm stays undefined when realms load async. - realm-dropdown.gts: Guard selectedItemText against undefined selectedRealm. Fixes Issue 7 (cosmetic "In undefined"). - mini/index.gts: Add handleFileSelectOnly action for onFileSelected, keeping onFileConfirmed as the sole trigger of onSelect. Fixes Issue 1 (double-fire on Enter).
The fix for Issue 1 (double-fire on Enter) changed click to only update visual selection (handleFileSelectOnly), not fire onSelect. The test needs to click the file then press Enter to confirm the selection.
Plan doc that informed the review-feedback fixes — drop it from the merged history per the workflow note about planning artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Background and Goal
Second primitive in the Markdown Editing UI sequence, sibling to
MiniCardChooser(CS-11672).MiniFileChooseris a compact, standalone, independently-mountable file picker sized to match the mini card chooser, so the two can sit side-by-side in the combined modal a later ticket builds.It renders a Workspace dropdown over the indexed file tree, plus an Upload… button and drag-and-drop upload. It reuses the machinery already proven in
ChooseFileModal—RealmDropdown,IndexedFileTree, and thefile-uploadservice — rather than reimplementing any of it. The primitive only firesonSelect(url)with the picked or uploaded file's absolute URL; the hosting container owns confirmation/dismissal (no footer / Add / Cancel), matchingMiniCardChooser.File-embed serialization decision
The ticket asked us to decide and document how downstream tickets serialize file embeds, framing it as
:card[URL]vs a plain markdown link[name](URL), and said do not add new BFM syntax.Decision: serialize file embeds as
:file[URL](block::file[URL]).This is not new syntax — BFM already has a dedicated, purpose-built file reference that resolves to a
FileDefand renders as an embed: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'It is strictly better than the two framed options:
:card[URL]would route a file through card rendering even thoughFileDefhas dedicated file rendering, and a plain[name](URL)link renders as an anchor, not an embed.MiniFileChooseritself returns only a URL; downstream tickets apply the:file[URL]serialization.Where to start
packages/host/app/components/file-chooser/mini/index.gts— the component. NoteselectFile(resolves the tree path to an absolute URL viaRealmPaths.fileURL) andbeginUpload(firesonSelectwithFileDef.sourceUrlonce upload completes).packages/host/app/components/file-chooser/mini/usage.gts+ thehost-freestyle.gtsregistration — Freestyle entry for manual review.Key decisions and non-obvious mechanics
IndexedFileTreeto be torn down and rebuilt when the realm changes, mirroringChooseFileModal.@selectedhighlight. The user's in-tree pick wins; otherwise we derive aLocalPathfrom@selectedonly when it lives inside the open workspace (RealmPaths.inRealm/.local).Drop file to upload to <realm>label) via thedata-drop-zone-active/data-drop-zone-labelattributes.Screenshot