From 446d15b7da59ad574070023f7396b4635f46e310 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 4 Feb 2026 18:24:12 -0500 Subject: [PATCH 1/5] Populate ChooseFile modal via file-meta entries in index Replace HTTP directory listing requests with indexed file-meta queries for the ChooseFile modal. Instead of making separate HTTP requests for each directory level as users expand the tree, we now query all files at once via the search API and build the tree client-side. Changes: - Add FileTreeFromIndexResource that wraps SearchResource with FileDef type filter and builds a tree structure from flat file instances - Add IndexedFileTree component that renders the tree using the resource - Update ChooseFileModal to use IndexedFileTree instead of FileTree The new implementation leverages SearchResource for Store integration, caching, reference counting, and live event subscription. Closes CS-10109 Co-Authored-By: Claude Opus 4.5 --- .../components/editor/indexed-file-tree.gts | 244 ++++++++++++++++++ .../operator-mode/choose-file-modal.gts | 4 +- .../app/resources/file-tree-from-index.ts | 164 ++++++++++++ 3 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 packages/host/app/components/editor/indexed-file-tree.gts create mode 100644 packages/host/app/resources/file-tree-from-index.ts diff --git a/packages/host/app/components/editor/indexed-file-tree.gts b/packages/host/app/components/editor/indexed-file-tree.gts new file mode 100644 index 00000000000..303e5a7b4b6 --- /dev/null +++ b/packages/host/app/components/editor/indexed-file-tree.gts @@ -0,0 +1,244 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { restartableTask, timeout } from 'ember-concurrency'; +import { TrackedSet } from 'tracked-built-ins'; + +import { eq } from '@cardstack/boxel-ui/helpers'; +import { DropdownArrowDown } from '@cardstack/boxel-ui/icons'; + +import type { LocalPath } from '@cardstack/runtime-common/paths'; + +import scrollIntoViewModifier from '@cardstack/host/modifiers/scroll-into-view'; +import { + fileTreeFromIndex, + type FileTreeNode, +} from '@cardstack/host/resources/file-tree-from-index'; +import { normalizeDirPath } from '@cardstack/host/utils/normalized-dir-path'; + +interface Signature { + Args: { + realmURL: string; + selectedFile?: LocalPath; + openDirs?: LocalPath[]; + onFileSelected?: (entryPath: LocalPath) => void; + onDirectorySelected?: (entryPath: LocalPath) => void; + scrollPositionKey?: LocalPath; + }; +} + +export default class IndexedFileTree extends Component { + + + private fileTree = fileTreeFromIndex(this, () => this.args.realmURL); + private localOpenDirs = new TrackedSet(); + @tracked private selectedFile?: LocalPath; + @tracked private showMask = true; + + constructor(owner: Owner, args: Signature['Args']) { + super(owner, args); + this.hideMask.perform(); + } + + private hideMask = restartableTask(async () => { + // fine tuned to coincide with debounce in RestoreScrollPosition modifier + await timeout(300); + this.showMask = false; + }); + + private get effectiveOpenDirs(): Set { + if (this.args.openDirs) { + return new Set(this.args.openDirs); + } + return this.localOpenDirs; + } + + @action + private selectFile(entryPath: LocalPath) { + this.selectedFile = entryPath; + this.args.onFileSelected?.(entryPath); + } + + @action + private toggleDirectory(entryPath: LocalPath) { + let dirPath = normalizeDirPath(entryPath); + + if (this.localOpenDirs.has(dirPath)) { + this.localOpenDirs.delete(dirPath); + } else { + this.localOpenDirs.add(dirPath); + } + + this.args.onDirectorySelected?.(dirPath); + } +} + +interface TreeLevelSignature { + Args: { + entries: FileTreeNode[]; + fileTree: ReturnType; + selectedFile?: LocalPath; + openDirs: Set; + onFileSelected: (entryPath: LocalPath) => void; + onDirectorySelected: (entryPath: LocalPath) => void; + scrollPositionKey?: LocalPath; + relativePath: string; + }; +} + +class TreeLevel extends Component { + + + @action + isSelectedFile(path: string): boolean { + return this.args.selectedFile === path; + } + + @action + isOpenDirectory(path: string): boolean { + let dirPath = normalizeDirPath(path); + return this.args.openDirs.has(dirPath); + } + + @action + getChildren(entry: FileTreeNode): FileTreeNode[] { + if (!entry.children) { + return []; + } + return Array.from(entry.children.values()); + } +} diff --git a/packages/host/app/components/operator-mode/choose-file-modal.gts b/packages/host/app/components/operator-mode/choose-file-modal.gts index 91877d0e26f..78775a6ebdc 100644 --- a/packages/host/app/components/operator-mode/choose-file-modal.gts +++ b/packages/host/app/components/operator-mode/choose-file-modal.gts @@ -31,7 +31,7 @@ import type RealmService from '@cardstack/host/services/realm'; import type { FileDef } from 'https://cardstack.com/base/file-api'; -import FileTree from '../editor/file-tree'; +import IndexedFileTree from '../editor/indexed-file-tree'; interface Signature { Args: {}; @@ -218,7 +218,7 @@ export default class ChooseFileModal extends Component { @label='Choose File' @tag='div' > - diff --git a/packages/host/app/resources/file-tree-from-index.ts b/packages/host/app/resources/file-tree-from-index.ts new file mode 100644 index 00000000000..9e35bcb2662 --- /dev/null +++ b/packages/host/app/resources/file-tree-from-index.ts @@ -0,0 +1,164 @@ +import { getOwner } from '@ember/owner'; +import type Owner from '@ember/owner'; +import { tracked, cached } from '@glimmer/tracking'; + +import { Resource } from 'ember-modify-based-class-resource'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common/query'; + +import type { FileDef } from 'https://cardstack.com/base/file-api'; + +import { getSearch, type SearchResource } from './search'; + +interface Args { + named: { + realmURL: string; + }; +} + +export interface FileTreeNode { + name: string; + path: string; // Relative path from realm root + kind: 'file' | 'directory'; + children?: Map; +} + +export class FileTreeFromIndexResource extends Resource { + @tracked private realmURL: string | undefined; + private search: SearchResource | undefined; + + modify(_positional: never[], named: Args['named']) { + let { realmURL } = named; + let normalizedURL = ensureTrailingSlash(realmURL); + + if (this.realmURL === normalizedURL) { + return; + } + + this.realmURL = normalizedURL; + + // Create search resource for FileDef type in this realm + let owner = getOwner(this) as Owner; + this.search = getSearch( + this, + owner, + () => this.query, + () => (this.realmURL ? [this.realmURL] : undefined), + { isLive: true }, + ); + } + + private get query(): Query | undefined { + if (!this.realmURL) { + return undefined; + } + return { + filter: { + type: { + module: 'https://cardstack.com/base/file-api', + name: 'FileDef', + }, + }, + }; + } + + get isLoading(): boolean { + return this.search?.isLoading ?? true; + } + + @cached + get entries(): FileTreeNode[] { + if (!this.search || !this.realmURL) { + return []; + } + + let files = this.search.instances; + let tree = this.buildTreeFromFiles(files); + return this.sortEntries(tree); + } + + private buildTreeFromFiles(files: FileDef[]): Map { + let root = new Map(); + + for (let file of files) { + // Extract relative path from file URL + // The file id is the full URL like "http://localhost:4200/myworkspace/path/to/file.txt" + // We need just "path/to/file.txt" + let relativePath = file.id.replace(this.realmURL!, ''); + + // Skip if the path is empty or just the realm root + if (!relativePath || relativePath === '/') { + continue; + } + + // Split path into segments: "foo/bar/baz.txt" -> ["foo", "bar", "baz.txt"] + let segments = relativePath.split('/').filter(Boolean); + + let currentLevel = root; + let currentPath = ''; + + for (let i = 0; i < segments.length; i++) { + let segment = segments[i]; + let isLastSegment = i === segments.length - 1; + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + + if (!currentLevel.has(segment)) { + currentLevel.set(segment, { + name: segment, + path: isLastSegment ? currentPath : `${currentPath}/`, + kind: isLastSegment ? 'file' : 'directory', + children: isLastSegment ? undefined : new Map(), + }); + } else if (!isLastSegment) { + // Ensure intermediate nodes are directories with children + let existingNode = currentLevel.get(segment)!; + if (!existingNode.children) { + existingNode.children = new Map(); + existingNode.kind = 'directory'; + existingNode.path = `${currentPath}/`; + } + } + + if (!isLastSegment) { + currentLevel = currentLevel.get(segment)!.children!; + } + } + } + + return root; + } + + private sortEntries(tree: Map): FileTreeNode[] { + let entries = Array.from(tree.values()); + + // Sort: directories first, then alphabetically by name + entries.sort((a, b) => { + // Directories come before files + if (a.kind === 'directory' && b.kind === 'file') { + return -1; + } + if (a.kind === 'file' && b.kind === 'directory') { + return 1; + } + // Within same kind, sort alphabetically + return a.name.localeCompare(b.name); + }); + + // Recursively sort children + for (let entry of entries) { + if (entry.children) { + let sortedChildren = this.sortEntries(entry.children); + entry.children = new Map(sortedChildren.map((e) => [e.name, e])); + } + } + + return entries; + } +} + +export function fileTreeFromIndex(parent: object, realmURL: () => string) { + return FileTreeFromIndexResource.from(parent, () => ({ + realmURL: realmURL(), + })) as FileTreeFromIndexResource; +} From b401f280adf26d89b14f6b89044baf430e57ad0d Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 5 Feb 2026 11:25:28 -0500 Subject: [PATCH 2/5] Fix autotracking error in FileTreeFromIndexResource Use private class field (#realmURL) instead of tracked property to avoid "attempted to update but it had already been used" error during render. Private fields are not automatically tracked by Glimmer, which prevents the conflict when modify() updates the URL after entries getter has read it. Co-Authored-By: Claude Opus 4.5 --- .../app/resources/file-tree-from-index.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/host/app/resources/file-tree-from-index.ts b/packages/host/app/resources/file-tree-from-index.ts index 9e35bcb2662..5150a84ff0c 100644 --- a/packages/host/app/resources/file-tree-from-index.ts +++ b/packages/host/app/resources/file-tree-from-index.ts @@ -1,12 +1,13 @@ import { getOwner } from '@ember/owner'; import type Owner from '@ember/owner'; -import { tracked, cached } from '@glimmer/tracking'; +import { cached } from '@glimmer/tracking'; import { Resource } from 'ember-modify-based-class-resource'; import { ensureTrailingSlash } from '@cardstack/runtime-common'; import type { Query } from '@cardstack/runtime-common/query'; +import type { CardDef } from 'https://cardstack.com/base/card-api'; import type { FileDef } from 'https://cardstack.com/base/file-api'; import { getSearch, type SearchResource } from './search'; @@ -25,32 +26,34 @@ export interface FileTreeNode { } export class FileTreeFromIndexResource extends Resource { - @tracked private realmURL: string | undefined; - private search: SearchResource | undefined; + // Use private field to avoid Glimmer autotracking - this prevents the error: + // "You attempted to update `realmURL` but it had already been used previously in the same computation" + #realmURL: string | undefined; + private search: SearchResource | undefined; modify(_positional: never[], named: Args['named']) { let { realmURL } = named; let normalizedURL = ensureTrailingSlash(realmURL); - if (this.realmURL === normalizedURL) { + if (this.#realmURL === normalizedURL) { return; } - this.realmURL = normalizedURL; + this.#realmURL = normalizedURL; // Create search resource for FileDef type in this realm let owner = getOwner(this) as Owner; - this.search = getSearch( + this.search = getSearch( this, owner, () => this.query, - () => (this.realmURL ? [this.realmURL] : undefined), + () => (this.#realmURL ? [this.#realmURL] : undefined), { isLive: true }, ); } private get query(): Query | undefined { - if (!this.realmURL) { + if (!this.#realmURL) { return undefined; } return { @@ -69,11 +72,12 @@ export class FileTreeFromIndexResource extends Resource { @cached get entries(): FileTreeNode[] { - if (!this.search || !this.realmURL) { + if (!this.search || !this.#realmURL) { return []; } - let files = this.search.instances; + // We query with FileDef type filter, so instances are FileDef + let files = this.search.instances as FileDef[]; let tree = this.buildTreeFromFiles(files); return this.sortEntries(tree); } @@ -85,7 +89,7 @@ export class FileTreeFromIndexResource extends Resource { // Extract relative path from file URL // The file id is the full URL like "http://localhost:4200/myworkspace/path/to/file.txt" // We need just "path/to/file.txt" - let relativePath = file.id.replace(this.realmURL!, ''); + let relativePath = file.id.replace(this.#realmURL!, ''); // Skip if the path is empty or just the realm root if (!relativePath || relativePath === '/') { From bb36bc84015b1814e1c23d84e96b8cdc086c9210 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 5 Feb 2026 11:57:22 -0500 Subject: [PATCH 3/5] Remove early-return optimization in FileTreeFromIndexResource The comparison to skip re-creating the search was causing issues when switching realms - the search resource's realms function was evaluated with stale data. The SearchResource handles deduplication internally, so we can safely remove this optimization. Co-Authored-By: Claude Opus 4.5 --- packages/host/app/resources/file-tree-from-index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/host/app/resources/file-tree-from-index.ts b/packages/host/app/resources/file-tree-from-index.ts index 5150a84ff0c..0b1c7f12f83 100644 --- a/packages/host/app/resources/file-tree-from-index.ts +++ b/packages/host/app/resources/file-tree-from-index.ts @@ -35,10 +35,7 @@ export class FileTreeFromIndexResource extends Resource { let { realmURL } = named; let normalizedURL = ensureTrailingSlash(realmURL); - if (this.#realmURL === normalizedURL) { - return; - } - + // Always update - the search resource handles deduplication internally this.#realmURL = normalizedURL; // Create search resource for FileDef type in this realm From 07c14a0e546dc37965a6818853ba2d2e9b7a5db6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 5 Feb 2026 12:51:38 -0500 Subject: [PATCH 4/5] Force IndexedFileTree recreation when realm changes in ChooseFileModal Use {{#each}} with single-element array to force component recreation when the realm URL changes. This ensures the FileTreeFromIndexResource is properly re-initialized with the new realm URL. Co-Authored-By: Claude Opus 4.5 --- .../components/operator-mode/choose-file-modal.gts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/host/app/components/operator-mode/choose-file-modal.gts b/packages/host/app/components/operator-mode/choose-file-modal.gts index 78775a6ebdc..e9305baea6c 100644 --- a/packages/host/app/components/operator-mode/choose-file-modal.gts +++ b/packages/host/app/components/operator-mode/choose-file-modal.gts @@ -1,5 +1,5 @@ import { registerDestructor } from '@ember/destroyable'; -import { fn } from '@ember/helper'; +import { array, fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { action } from '@ember/object'; import type Owner from '@ember/owner'; @@ -218,10 +218,13 @@ export default class ChooseFileModal extends Component { @label='Choose File' @tag='div' > - + {{! Use #each with single-element array to force component recreation when realm changes }} + {{#each (array this.selectedRealm.url.href) as |realmURL|}} + + {{/each}} <:footer> From 0458b9ce871e88b84c0e85a209154b0410fe44af Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 5 Feb 2026 20:23:26 -0500 Subject: [PATCH 5/5] Add loading indicator to IndexedFileTree while query is running Show a centered LoadingIndicator spinner in the file tree mask overlay while the search query is still loading, so users see feedback instead of a blank white area in the ChooseFileModal. Co-Authored-By: Claude Opus 4.6 --- .../components/editor/indexed-file-tree.gts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/host/app/components/editor/indexed-file-tree.gts b/packages/host/app/components/editor/indexed-file-tree.gts index 303e5a7b4b6..a0adc60bd9e 100644 --- a/packages/host/app/components/editor/indexed-file-tree.gts +++ b/packages/host/app/components/editor/indexed-file-tree.gts @@ -8,6 +8,7 @@ import { tracked } from '@glimmer/tracking'; import { restartableTask, timeout } from 'ember-concurrency'; import { TrackedSet } from 'tracked-built-ins'; +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { DropdownArrowDown } from '@cardstack/boxel-ui/icons'; @@ -45,7 +46,11 @@ export default class IndexedFileTree extends Component { @relativePath='' /> {{#if this.showMask}} -
+
+ {{#if this.fileTree.isLoading}} + + {{/if}} +
{{/if}} @@ -57,6 +62,9 @@ export default class IndexedFileTree extends Component { background-color: white; height: 100%; width: 100%; + display: flex; + align-items: center; + justify-content: center; } nav { position: relative; @@ -67,17 +75,24 @@ export default class IndexedFileTree extends Component { private fileTree = fileTreeFromIndex(this, () => this.args.realmURL); private localOpenDirs = new TrackedSet(); @tracked private selectedFile?: LocalPath; - @tracked private showMask = true; + @tracked private maskDismissed = false; constructor(owner: Owner, args: Signature['Args']) { super(owner, args); this.hideMask.perform(); } + private get showMask(): boolean { + if (this.fileTree.isLoading) { + return true; + } + return !this.maskDismissed; + } + private hideMask = restartableTask(async () => { // fine tuned to coincide with debounce in RestoreScrollPosition modifier await timeout(300); - this.showMask = false; + this.maskDismissed = true; }); private get effectiveOpenDirs(): Set {