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..a0adc60bd9e --- /dev/null +++ b/packages/host/app/components/editor/indexed-file-tree.gts @@ -0,0 +1,259 @@ +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 { LoadingIndicator } from '@cardstack/boxel-ui/components'; +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 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.maskDismissed = true; + }); + + 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..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'; @@ -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,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> 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..0b1c7f12f83 --- /dev/null +++ b/packages/host/app/resources/file-tree-from-index.ts @@ -0,0 +1,165 @@ +import { getOwner } from '@ember/owner'; +import type Owner from '@ember/owner'; +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'; + +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 { + // 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); + + // Always update - the search resource handles deduplication internally + 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 []; + } + + // 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); + } + + 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; +}