Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions packages/host/app/components/editor/indexed-file-tree.gts
Original file line number Diff line number Diff line change
@@ -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<Signature> {
<template>
<nav>
<TreeLevel
@entries={{this.fileTree.entries}}
@fileTree={{this.fileTree}}
@selectedFile={{if @selectedFile @selectedFile this.selectedFile}}
@openDirs={{this.effectiveOpenDirs}}
@onFileSelected={{this.selectFile}}
@onDirectorySelected={{this.toggleDirectory}}
@scrollPositionKey={{@scrollPositionKey}}
@relativePath=''
/>
{{#if this.showMask}}
<div class='mask' data-test-file-tree-mask>
{{#if this.fileTree.isLoading}}
<LoadingIndicator />
{{/if}}
</div>
{{/if}}
</nav>

<style scoped>
.mask {
position: absolute;
top: 0;
left: 0;
background-color: white;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
nav {
position: relative;
}
</style>
</template>

private fileTree = fileTreeFromIndex(this, () => this.args.realmURL);
private localOpenDirs = new TrackedSet<string>();
@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<string> {
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<typeof fileTreeFromIndex>;
selectedFile?: LocalPath;
openDirs: Set<string>;
onFileSelected: (entryPath: LocalPath) => void;
onDirectorySelected: (entryPath: LocalPath) => void;
scrollPositionKey?: LocalPath;
relativePath: string;
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relativePath parameter is declared in the TreeLevelSignature but is never used in the TreeLevel component. It's passed down recursively but has no effect on the component's behavior. Consider removing it if it's not needed, or document why it's being tracked for future use.

Copilot uses AI. Check for mistakes.
};
}

class TreeLevel extends Component<TreeLevelSignature> {
<template>
{{#each @entries as |entry|}}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #each loop should include a key parameter for better rendering performance and correctness. The old Directory component uses key='path' which should be added here as well. Without a key, Ember may not correctly track which items have changed when the list updates, potentially causing rendering issues or unnecessary re-renders.

Suggested change
{{#each @entries as |entry|}}
{{#each @entries key="path" as |entry|}}

Copilot uses AI. Check for mistakes.
<div class='level' data-test-directory-level>
{{#if (eq entry.kind 'file')}}
<button
data-test-file={{entry.path}}
title={{entry.name}}
{{on 'click' (fn @onFileSelected entry.path)}}
{{scrollIntoViewModifier
(this.isSelectedFile entry.path)
container='file-tree'
key=@scrollPositionKey
}}
class='file {{if (this.isSelectedFile entry.path) "selected"}}'
>
{{entry.name}}
</button>
{{else}}
<button
data-test-directory={{entry.path}}
title={{entry.name}}
{{on 'click' (fn @onDirectorySelected entry.path)}}
class='directory'
>
<DropdownArrowDown
class='icon
{{if (this.isOpenDirectory entry.path) "open" "closed"}}'
/>{{entry.name}}
</button>
{{#if (this.isOpenDirectory entry.path)}}
<TreeLevel
@entries={{this.getChildren entry}}
@fileTree={{@fileTree}}
@selectedFile={{@selectedFile}}
@openDirs={{@openDirs}}
@onFileSelected={{@onFileSelected}}
@onDirectorySelected={{@onDirectorySelected}}
@scrollPositionKey={{@scrollPositionKey}}
@relativePath={{entry.path}}
/>
{{/if}}
{{/if}}
</div>
{{/each}}

<style scoped>
.level {
--icon-length: 14px;
--icon-margin: 4px;

padding-left: 0em;
}

.level .level {
padding-left: 1em;
}

.directory,
.file {
border-radius: var(--boxel-border-radius-xs);
background: transparent;
border: 0;
padding: var(--boxel-sp-xxxs);
width: 100%;
text-align: start;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.directory:hover,
.file:hover {
background-color: var(--boxel-200);
}

.file.selected,
.file:active {
color: var(--boxel-dark);
background-color: var(--boxel-highlight);
}

.directory {
padding-left: 0;
}

.directory :deep(.icon) {
width: var(--icon-length);
height: var(--icon-length);
margin-bottom: -2px;
padding: 0 2px;
}

.directory :deep(.icon.closed) {
transform: rotate(-90deg);
}

.file {
padding-left: calc(var(--icon-length) + var(--icon-margin));
}
</style>
</template>

@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());
}
}
15 changes: 9 additions & 6 deletions packages/host/app/components/operator-mode/choose-file-modal.gts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {};
Expand Down Expand Up @@ -218,10 +218,13 @@ export default class ChooseFileModal extends Component<Signature> {
@label='Choose File'
@tag='div'
>
<FileTree
@realmURL={{this.selectedRealm.url.href}}
@onFileSelected={{this.selectFile}}
/>
{{! Use #each with single-element array to force component recreation when realm changes }}
{{#each (array this.selectedRealm.url.href) as |realmURL|}}
<IndexedFileTree
@realmURL={{realmURL}}
@onFileSelected={{this.selectFile}}
/>
{{/each}}
</FieldContainer>
</:content>
<:footer>
Expand Down
Loading
Loading