Skip to content

Models, loading and expansion ​

The model knows the paths you give it and nothing else. Listings, search requests, caches, permissions and filesystem changes are yours; no model method does any I/O.

Sorting and flattened paths ​

FileTree.compareRows, the default sort, puts directories before files and compares names numerically, so file2 comes before file10. Case only breaks a tie, and a directory comes before what it holds. A sort of your own receives the engine's sort entries; SortRow is the part of one the default needs.

With flattenEmptyDirectories, one row can stand for src/utilities/ as joined segments. FileTree.pathOfRow(row) resolves such a row to its last directory, and FileTree.visibleRows(model) returns the visible rows with that path. renderControl and renderDecoration receive rows with that path too. Use these helpers when you save folds or load children, so an intermediate segment never becomes the target.

Changing paths ​

ts
import type { FileTreeModel } from '@adecore/ui';

export function addGeneratedFiles(model: FileTreeModel) {
    model.batch([
        { type: 'add', path: 'generated/types.ts' },
        { type: 'add', path: 'generated/schema.ts' }
    ]);
    model.move('generated/schema.ts', 'generated/schema-v2.ts', { collision: 'error' });
    model.remove('generated/types.ts');
}

batch groups adds, removes and moves. Removing a directory takes { recursive: true }, and a move takes a collision of 'error', 'replace' or 'skip'. Change the filesystem first and bring the model in line with what succeeded. Dragging and renaming in the tree change the model too, so code that updates it ahead of the filesystem must undo a step that failed.

Lazy listings ​

onLoadChildren(path) runs for every directory that opens, including the ones open at the start, and again after a close and a reopen. It returns void: the wrapper does not await it, shows no loading state, catches no error and cancels nothing.

The engine draws no chevron on an empty directory, so a directory whose children are not loaded yet needs a placeholder child. Start with paths: ['src/__pending__'], leave flattening off while it loads, and hide the placeholder through unsafeCSS with [data-item-path$="/__pending__"] { display: none; }. This relies on attributes of the pinned engine version.

A loader of your own can cache what loaded and cancel what is pending. listChildren returns full paths relative to the root, not bare names. Give each tree its own loader.

ts
import type { FileTreeModel } from '@adecore/ui';

type Listing = (path: string, signal: AbortSignal) => Promise<readonly string[]>;

export function createListingLoader(model: FileTreeModel, listChildren: Listing, onError: (path: string, error: unknown) => void) {
    const cached = new Set<string>();
    const pending = new Set<string>();
    const controller = new AbortController();
    const load = (path: string): void => {
        if (cached.has(path) || pending.has(path) || controller.signal.aborted) {
            return;
        }
        pending.add(path);
        void listChildren(path, controller.signal)
            .then((paths) => {
                if (controller.signal.aborted) {
                    return;
                }
                model.batch(paths.map((child) => ({ type: 'add' as const, path: child })));
                model.remove(`${path}__pending__`);
                cached.add(path);
            })
            .catch((error: unknown) => {
                if (!controller.signal.aborted) {
                    onError(path, error);
                }
            })
            .finally(() => {
                pending.delete(path);
            });
    };
    return {
        load,
        invalidate: (path: string) => {
            cached.delete(path);
        },
        dispose: () => controller.abort()
    };
}

Pass loader.load as onLoadChildren and call loader.dispose() on unmount or when the root changes. invalidate starts no request; call load(path) to refresh a row that is open. This loader only adds entries: a full refresh also removes the ones that are gone, and adds placeholders for new directories. Errors and a way to retry are yours to draw.

Remembering folds ​

Expansion lives in the model; there is no controlled expandedPaths prop. onExpandedPathsChange reports every change, including remembered paths that are hidden under a closed directory. The helpers reconcile what you stored with what is loaded:

ts
import { FileTree, type FileTreeModel } from '@adecore/ui';

export function restoreExpanded(model: FileTreeModel, paths: readonly string[], remembered: ReadonlySet<string>) {
    FileTree.resetExpandedPaths(model, paths, remembered);
    const rows = FileTree.visibleRows(model).filter((row) => row.kind === 'directory');
    const known = new Set(rows.map((row) => row.path));
    const reported = new Set(rows.filter((row) => row.isExpanded).map((row) => row.path));
    return FileTree.withoutClosedBranches(FileTree.mergeExpanded(remembered, reported, known), known);
}

resetExpandedPaths resets the paths and expands a second time, because the engine restores expansion with its default sort. mergeExpanded keeps remembered paths the model does not know yet. withoutClosedBranches drops paths under a known directory that is closed. Never store only what is visible, or the folds under a directory that has not loaded are lost.

To store collapsed directories instead, use FileTree.dirPathOf, FileTree.collapsedPathsOf, FileTree.mergeCollapsedPaths and FileTree.applyExpansion, with the same FoldKeyOf both ways; a key of null leaves a directory out. mergeCollapsedPaths returns the same array when nothing changed, since a selection also notifies the model's subscribers. applyExpansion runs at most 32 passes, as an opened directory can reveal more flattened ones.

FileTree.ancestorDirsOf(path) lists the directories above a path, outermost first, and FileTree.newlyExpanded(before, after) the paths that opened between two sets.

Search and subscriptions ​

The engine's search is off by default; turn it on with search. openSearch, setSearch, closeSearch, getSearchMatchingPaths and the next and previous match methods work on the paths the model knows and never query the filesystem. For results from a recursive or remote search, use a second model rather than replacing the listing.

subscribe and onMutation return a function that unsubscribes. Selection changes notify subscribers too, so store expansion only when it changed.

Licenses and third-party notices are listed in each package.