File tree view
Show records as folders and files, with lazy server loading, drag and drop, Move to…, undo and a details pane.
The File tree view shows the records of a table as folders and files, like the sidebar of Finder, Google Drive or VS Code. It is built for asset managers: next to the Gallery, which shows thumbnails, and the table, which shows metadata, people browse and reorganize the same assets by folder. React and Vue behave the same.
The table owns the tree and its interface. Your application owns storage, the hierarchy on the server, permissions and the moves themselves.
Enable
The File tree ships in the table items and adds no dependency. Records are linked by a parent column that holds the id of their parent folder (empty for root items). List "filetree" in displayModes:
export const assetsConfig = defineTableConfig({
...projectConfig,
table: {
...projectConfig.table,
displayModes: ["filetree", "gallery", "table"],
defaultDisplayMode: "filetree",
gallery: {
titleColumn: "name",
imageColumn: "url",
media: { enabled: true, urlColumn: "url", mimeTypeColumn: "mimeType" },
},
filetree: {
parentColumn: "parentId",
kindColumn: "kind",
nameColumn: "name",
sizeColumn: "size",
updatedColumn: "updatedAt",
detailFields: ["mimeType"],
rootLabel: "Assets",
},
},
});The mode is offered when table.filetree.parentColumn is set, or when the table has a column named parentId, parent_id, parent, folderId, folder_id or folder. table.filetree is optional: an object of default settings (the shape below) that every File tree view starts from, plus the host hooks, or false to turn the mode off. With false, "filetree" is not offered even if listed in displayModes, and a link asking for it falls back to the default mode. A renderer passed as displayModeRenderers.filetree replaces the built-in one.
Try the file tree
A brand library's folders and files live in one table, linked by parentId. Use Expand all and Collapse all, open a folder with its chevron or the arrow keys, drag a file onto another folder or use Move to…, create a folder, and open Details for the preview and actions. This in-memory host applies no scope, so the tree uses the browser fallbacks. Switch Display mode to Gallery or Table to see the same records. The folders and files are shared by React and Vue, and the host is the example setup's createMemoryActions.
Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table";
import { assetColumns } from "../shared/assets";
export const exampleConfig = defineTableConfig({
id: "assets",
translations: { namespace: "assets", keys: { title: "Brand assets" } },
columns: {
definitions: assetColumns,
order: assetColumns.map((column) => column.id),
visible: ["name", "size", "updatedAt"],
mandatory: ["name"],
},
table: {
allowCreate: true,
allowEdit: true,
allowDelete: true,
enableRowSelection: true,
enableViews: false,
syncUrl: false,
displayModes: ["filetree", "gallery", "table"],
defaultDisplayMode: "filetree",
gallery: {
titleColumn: "name",
imageColumn: "url",
media: { enabled: true, urlColumn: "url", mimeTypeColumn: "mimeType" },
},
filetree: {
parentColumn: "parentId",
kindColumn: "kind",
nameColumn: "name",
sizeColumn: "size",
updatedColumn: "updatedAt",
detailFields: ["mimeType"],
rootLabel: "Brand assets",
showDetails: true,
},
},
});Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table-vue";
import { assetColumns } from "../shared/assets";
export const exampleConfig = defineTableConfig({
id: "assets",
translations: { namespace: "assets", keys: { title: "Brand assets" } },
columns: {
definitions: assetColumns,
order: assetColumns.map((column) => column.id),
visible: ["name", "size", "updatedAt"],
mandatory: ["name"],
},
table: {
allowCreate: true,
allowEdit: true,
allowDelete: true,
enableRowSelection: true,
enableViews: false,
syncUrl: false,
displayModes: ["filetree", "gallery", "table"],
defaultDisplayMode: "filetree",
gallery: {
titleColumn: "name",
imageColumn: "url",
media: { enabled: true, urlColumn: "url", mimeTypeColumn: "mimeType" },
},
filetree: {
parentColumn: "parentId",
kindColumn: "kind",
nameColumn: "name",
sizeColumn: "size",
updatedColumn: "updatedAt",
detailFields: ["mimeType"],
rootLabel: "Brand assets",
showDetails: true,
},
},
});Folders and files
Each record is a node. A record is a folder when its kind column holds "folder"; without a kind column, when it has children or when the isFolder hook returns true. Everything else is a file.
The view is a table with the tree in its Name column (the WAI-ARIA treegrid pattern):
Header: the root label (the table's name by default), a summary of the known nodes such as "6 folders · 15 files", the view actions New folder, Expand all, Collapse all and Details, and the breadcrumbs of the current folder.
Rows: a selection checkbox, then the name with its indentation (it stops growing after 8 levels, the breadcrumbs show the full path), a chevron for folders, a colored icon (open or closed folder, or the file type: image, video, audio, PDF or document, archive, code, generic file) and the child count when known. Then the view's other columns, which hide on narrow widths, and a row menu.
Size is right-aligned in decimal units (
1.2 MB). Folders show--unless your server returns their totals. Modified is a relative date ("2 hours ago", "Yesterday").Column headers sort the tree. Folders stay first in every folder, and names use a natural, locale-aware order (
file2beforefile10).
File icons and previews reuse the gallery's media settings (table.gallery.media: urlColumn, mimeTypeColumn, getMedia), then the MIME type or the file extension.
When several items are selected, a floating bar appears below the tree without moving the rows: "3 selected · Move to… · Delete · Clear selection". The tree keeps its own selection, which spans folders that are not loaded on the same page. The table's page pagination is hidden in this mode, since the tree pages each folder itself.
Details pane
The details pane is optional (showDetails, off by default). It opens from Details in the header or Info in a row menu, and can be resized by dragging its edge or with the arrow keys on it. It shows a preview (the media thumbnail, which opens the native media viewer, or a large icon), the name, kind, location, item count, the view's columns and detailFields, and the actions Preview, Open, Rename, Move to…, New folder and Delete. renderDetails replaces its body.
Expand all and Collapse all
Expand all opens every folder, not only the loaded ones. With the subtree scope, the whole tree comes in one request. Without it, folders are loaded breadth first, level by level, up to 2,000 items, and the view reads "Expanded the first 2,000 items" when it stops there. Collapse all closes every folder. The choice is saved with the view (expandedAll).
Keyboard
The tree is one tab stop with a roving focus on its rows; controls inside rows have keyboard equivalents.
| Keys | Action |
|---|---|
| ↑ ↓, Home, End | Move the focus. |
| → ← | Expand or collapse. → on an open folder goes to its first child, ← on a child goes to its parent. |
| Enter, double click | Folder: expand or collapse. File: open the media preview, or the record view for other files, following the row click settings. |
| Space, Shift+↑↓, Shift/Cmd-click, Ctrl/Cmd+A | Toggle, extend or select all. |
* | Expand every folder beside the focused row. |
| Alt+Shift+↓, Alt+Shift+↑ | Expand all, Collapse all (announced with aria-keyshortcuts on the buttons). |
| F2 | Rename inline. |
| Ctrl/Cmd+X, then Ctrl/Cmd+V on a row | Move the cut items into that folder, or into the file's folder. Escape cancels. |
| Delete, Backspace | Delete, after a confirmation. |
| Shift+F10, context menu key | Open the row menu. |
| Ctrl/Cmd+Z | Undo the last move. |
| Letters | Type-ahead: jump to the next visible item starting with the typed letters. |
Loading, moves, renames and new folders are announced in a polite live region. aria-level, aria-setsize and aria-posinset come from the tree, so they stay correct when long lists are windowed.
Moving items
Drag and drop: drag rows onto a folder. The target is highlighted, a folder opens after 600 ms of hovering, the list scrolls near its edges, and a label follows the pointer. Dropping onto a file moves into that file's folder; dropping onto the header moves to the root.
Invalid targets show a not-allowed cursor and the reason: "You can't move a folder into itself", "You can't move a folder into one of its subfolders", "Items can't be moved into Unfiled", "You don't have permission to move here" (from
canMove) and "Already in this folder".Move to…, in the row menu, the details pane and the selection bar, opens a folder picker: a searchable tree where invalid targets are disabled with their reason. It works everywhere, including phones and keyboards.
Cut and paste: Ctrl/Cmd+X then Ctrl/Cmd+V, as in the keyboard table.
Moves are optimistic: the tree changes at once, then calls your server. Items your server refuses go back where they were, and the first error is shown. The notification carries Undo ("Moved 3 items to Brand › 2026 · Undo"), and Ctrl/Cmd+Z reverts the last move.
New folder adds a name field in the focused folder (or the focused file's folder, the root when nothing is focused, the open folder on phones); an empty name becomes "New folder". F2 or Rename edits the name inline (a server error, such as a name clash, stays on the row), and Delete asks for a confirmation first. When a folder is deleted, your server decides what happens: delete its content, refuse while it is not empty, or move its children up. The view shows your message.
Phones
On phones there is no details pane. Tapping a folder drills into it, with the breadcrumbs and a Back button; tapping a file opens its preview or record. The checkboxes are always visible for selection. Moves use Move to…, whose folder picker becomes a bottom sheet; touch drags are off.
Folders in the other views
A table whose rows form a file tree offers its folders in its other views too (table, list, gallery, Kanban, calendar, map), so people do not have to switch to the File tree:
New folder in the toolbar (not in the File tree, which has its own, nor in the Form view or Gantt), when folders can be created: with
tree.createFolder, elsecreatewithallowCreate. A dialog asks for the name and the parent folder, in a searchable picker listing the root first and each folder with its location. It starts in the folder the view is filtered on (one folder), else the root. The name follows the File tree's rules (trimmed; blank is "New folder"),canCreateFolder(parent)is asked, your server's error (such as a name already taken) shows in the dialog, and the table reloads after it.The folder filter: the parent column filters with the same picker in the filter menus; see Filter by folder. A facet on the parent column lists the same folders with their numbers of records.
table.filetree.newFolderAction: false and table.filetree.folderFilter:
false turn them off. The folders load once, when a picker or a facet first
needs them, and again after the table's data changes: list with
scope: { kind: "subtree", parentId: null } and, with a kind column, a rule
keeping folders, up to 2,000. A server that does not apply the scope gets the
capped all-rows loader, and the folders are picked from the rows.
Search and filters
While the view has a search or filters, the tree shows the matching items with their ancestor folders expanded and the matches highlighted, and hides the other siblings. The matches come from the tree-matches scope, or otherwise from the list query, with the ancestors taken from the rows loaded without it (both capped, with a notice when only part is shown).
File tree settings
Choose File tree under View settings › Display mode, then open Card settings in the same menu. It offers the parent, name, kind, size and modified columns, a columns preset (size and modified, size, modified, name only), the details pane, folders first and the depth opened on first load. Column header clicks set the sort.
Each view saves its settings (config.filetree), also kept in the <tableId>-filetree URL key. The folder you are in is kept in <tableId>-folder, so a link opens the same folder, for example ?assets-display=filetree&assets-folder=campaigns-2026.
| Setting | Default | Description |
|---|---|---|
parentColumn | Detected from the column names above | Id of the parent folder; empty for root items. |
kindColumn | None | "folder" or "file". Without it, a record with children, or for which isFolder returns true, is a folder. |
nameColumn | A column named name, title, filename or label, else the first text column | The node's label. |
sizeColumn | None | Sizes in bytes. |
updatedColumn | None | Last modification date, shown relative. |
columns | The size and modified columns | Column ids shown after Name. |
showDetails | false | Show the details pane (desktop). |
detailFields | [] | Extra columns listed in the details pane. |
foldersFirst | true | Folders before files in every folder. |
sort | The view's sort, then the name | { id, desc? }, set by column header clicks. |
defaultExpandedDepth | 1 | Folder levels opened on first load: 0, 1 (top-level folders open) or 2. |
rootLabel | The table's name, else "All files" | Label of the root in the header and breadcrumbs. |
expanded | [] | Ids of the open folders, saved with the view (500 at most). |
expandedAll | false | Every folder open, after Expand all. |
View settings win over table.filetree, which wins over these defaults. Host hooks are runtime callbacks and are never saved in views or URLs. table.filetree also takes newFolderAction and folderFilter (both true by default), which apply to the other views and are not view settings.
Loading from your server
A tree of 100,000 assets must not load at once, so the tree asks actions.list for one folder at a time. It adds a scope to the usual parameters, like the calendar's date range, and your server confirms it applied the scope with meta.scope: "applied". Every scope is additive: a server that ignores it still works through the fallbacks.
Children
list({ ...query, scope: { kind: "children", parentId: "brand" }, page: 1, pageSize: 200 })
// → { data, meta: { scope: "applied", totalCount, childCounts?, sizes? } }parentId: null is the root. The request carries the view's sort but not its search or filters. Answer the folder's direct children, one page at a time (200 per page), folders first then in the requested order, with:
meta.totalCount: the folder's number of children. A folder with more than 200 shows Show more (N).meta.childCounts(optional):{ [folderId]: number }for the folders indata, so the view shows the expanders and counts without loading grandchildren. Without it, every folder has an expander that may turn out empty.meta.sizes(optional):{ [folderId]: bytes }, the totals shown in the Size column for folders.
Loaded folders are cached, and reloaded after moves, new folders, renames, deletes, table changes and Refresh. A new query or new column settings clear the cache.
Subtree (Expand all)
list({ ...query, scope: { kind: "subtree", parentId: null }, pageSize: 2000 })
// → { data: every descendant, meta: { scope: "applied", truncated?, childCounts?, sizes? } }Answer every descendant of parentId. You may cap the answer; set meta.truncated: true when you do, and the view says so.
Search (tree matches)
list({ ...query, scope: { kind: "tree-matches" } })
// → { data: matches, meta: { scope: "applied", ancestors: Row[], truncated? } }Sent while the view has a search or filters, with them. Answer the matching records in data, and in meta.ancestors their ancestor folders that do not match themselves.
Path, move and new folder
Three optional actions live under actions.tree (see Actions API):
| Action | Signature | Without it |
|---|---|---|
| Path | tree.path(id) → Row[]: the ancestors of a node, root first, for breadcrumbs and links to a folder. | The parent column is walked over the loaded rows. |
| Move | tree.move({ ids, parentId }) → { moved?, failed? }: one batch; failed is [{ id, error? }]. | update(id, { [parentColumn]: parentId }) for each record. |
| New folder | tree.createFolder({ parentId, name }) → Row | create with the name, the parent and kindColumn: "folder". |
Renames go through update of the name column and deletes through delete, one record at a time. Your server re-checks everything: permissions, cycles and name clashes. The interface hooks only hide what the user cannot do.
Fallbacks
When your list does not answer meta.scope: "applied", the view loads rows through the table's capped all-rows loader (2,000 rows, with the usual notice when there are more) and builds the tree in the browser. A record whose parent is unknown, or whose parent would create a cycle, is then shown in a virtual Unfiled folder rather than lost. With the children scope, placing such records is your server's choice.
Server example
This PostgreSQL example stores assets in one table with a parent_id column. The children query returns child counts and folder sizes, recursive queries answer the subtree, the search ancestors and the path, and the move checks cycles and name clashes in one transaction. Add your organization and permission filters to every query, and check the session in every server action.
create table assets (
id uuid primary key default gen_random_uuid(),
parent_id uuid references assets (id),
kind text not null check (kind in ('folder', 'file')),
name text not null,
size bigint,
mime_type text,
url text,
updated_at timestamptz not null default now()
);
create index assets_parent on assets (parent_id);
-- One name per folder, the root included (PostgreSQL 15+).
create unique index assets_name_per_folder
on assets (parent_id, lower(name)) nulls not distinct;"use server";
import { Pool } from "pg";
const pool = new Pool();
const MAX_PAGE = 200;
const MAX_SUBTREE = 2000;
const COLUMNS = `id, parent_id as "parentId", kind, name, size::float8 as size,
mime_type as "mimeType", url, updated_at as "updatedAt"`;
const SORTABLE: Record<string, string> = {
name: "lower(name)",
size: "size",
updatedAt: "updated_at",
};
type Row = Record<string, unknown>;
type Scope =
| { kind: "children"; parentId: string | null }
| { kind: "subtree"; parentId: string | null }
| { kind: "tree-matches" };
/** Folders first, then the view's sort from an allow-list (never raw input). */
function orderBy(sorting: unknown): string {
const first = Array.isArray(sorting) ? sorting[0] : undefined;
const column = SORTABLE[String(first?.id)] ?? SORTABLE.name;
return `kind = 'folder' desc, ${column} ${first?.desc ? "desc" : "asc"}, id`;
}
/** Child counts and total sizes of the folders among `rows`. */
async function folderMeta(rows: Row[]) {
const ids = rows.filter((row) => row.kind === "folder").map((row) => row.id);
const counts = await pool.query(
`select parent_id as id, count(*)::int as count
from assets where parent_id = any($1::uuid[]) group by parent_id`,
[ids]
);
const sizes = await pool.query(
`with recursive tree as (
select id as root, id from assets where id = any($1::uuid[])
union
select tree.root, a.id from assets a join tree on a.parent_id = tree.id
)
select tree.root as id, coalesce(sum(a.size), 0)::float8 as size
from tree join assets a on a.id = tree.id group by tree.root`,
[ids]
);
return {
childCounts: Object.fromEntries(counts.rows.map((r) => [r.id, r.count])),
sizes: Object.fromEntries(sizes.rows.map((r) => [r.id, r.size])),
};
}
async function listChildren(parentId: string | null, params: Row) {
const page = Math.max(1, Number(params.page) || 1);
const pageSize = Math.min(MAX_PAGE, Math.max(1, Number(params.pageSize) || MAX_PAGE));
const [rows, total] = await Promise.all([
pool.query(
`select ${COLUMNS} from assets
where parent_id is not distinct from $1
order by ${orderBy(params.sorting)} limit $2 offset $3`,
[parentId, pageSize, (page - 1) * pageSize]
),
pool.query(
"select count(*)::int as count from assets where parent_id is not distinct from $1",
[parentId]
),
]);
return {
data: rows.rows,
meta: {
scope: "applied",
totalCount: total.rows[0].count,
pageCount: Math.ceil(total.rows[0].count / pageSize),
...(await folderMeta(rows.rows)),
},
};
}
async function listSubtree(parentId: string | null) {
// Breadth first, so a capped answer keeps the top of the tree.
const { rows } = await pool.query(
`with recursive tree as (
select id, 1 as depth from assets where parent_id is not distinct from $1
union all
select a.id, tree.depth + 1 from assets a join tree on a.parent_id = tree.id
)
select ${COLUMNS} from tree join assets using (id)
order by tree.depth limit $2`,
[parentId, MAX_SUBTREE + 1]
);
const data = rows.slice(0, MAX_SUBTREE);
return {
data,
meta: {
scope: "applied",
totalCount: data.length,
truncated: rows.length > MAX_SUBTREE,
...(await folderMeta(data)),
},
};
}
async function listMatches(params: Row) {
const search = `%${String(params.search ?? "")}%`;
const matches = await pool.query(
`select ${COLUMNS} from assets
where name ilike $1 -- and your list filters
order by lower(name) limit $2`,
[search, MAX_SUBTREE]
);
const ids = matches.rows.map((row) => row.id);
// "union" (not "union all") stops on already visited folders.
const ancestors = await pool.query(
`with recursive up as (
select parent_id as id from assets where id = any($1::uuid[]) and parent_id is not null
union
select a.parent_id from assets a join up on a.id = up.id where a.parent_id is not null
)
select ${COLUMNS} from assets where id in (select id from up) and id <> all($1::uuid[])`,
[ids]
);
return {
data: matches.rows,
meta: {
scope: "applied",
totalCount: matches.rows.length,
ancestors: ancestors.rows,
truncated: matches.rows.length >= MAX_SUBTREE,
},
};
}
export async function listAssets(params: Row) {
const scope = params.scope as Scope | undefined;
if (scope?.kind === "children") {
return await listChildren(scope.parentId ?? null, params);
}
if (scope?.kind === "subtree") {
return await listSubtree(scope.parentId ?? null);
}
if (scope?.kind === "tree-matches") {
return await listMatches(params);
}
return await listAssetPage(params); // your usual paged list for Gallery and Table
}
/** Ancestors of a node, root first. */
export async function assetPath(id: string) {
const { rows } = await pool.query(
`with recursive up as (
select id, parent_id, 0 as depth from assets where id = $1
union all
select a.id, a.parent_id, up.depth + 1 from assets a
join up on a.id = up.parent_id where up.depth < 100
)
select ${COLUMNS} from up join assets using (id)
where up.id <> $1 order by up.depth desc`,
[id]
);
return rows;
}
export async function moveAssets(input: { ids: string[]; parentId: string | null }) {
const { ids, parentId } = input;
const client = await pool.connect();
const moved: string[] = [];
const failed: { id: string; error: string }[] = [];
try {
await client.query("begin");
// One tree change at a time, so two concurrent moves cannot form a cycle.
await client.query("select pg_advisory_xact_lock(hashtext('assets-tree'))");
// The target and its ancestors: moving any of them into the target is a cycle.
const lineage = new Set<string>();
if (parentId) {
const target = await client.query("select kind from assets where id = $1", [parentId]);
if (target.rows[0]?.kind !== "folder") {
throw new Error("Items can only go into folders.");
}
const up = await client.query(
`with recursive up as (
select id, parent_id from assets where id = $1
union
select a.id, a.parent_id from assets a join up on a.id = up.parent_id
)
select id from up`,
[parentId]
);
for (const row of up.rows) {
lineage.add(row.id);
}
}
for (const id of ids) {
if (lineage.has(id)) {
failed.push({ id, error: "You can't move a folder into itself or one of its subfolders." });
continue;
}
const result = await client.query(
`update assets set parent_id = $2, updated_at = now()
where id = $1 and not exists (
select 1 from assets other
where other.parent_id is not distinct from $2
and lower(other.name) = lower(assets.name) and other.id <> $1)`,
[id, parentId]
);
if (result.rowCount) {
moved.push(id);
} else {
failed.push({ id, error: "An item with this name is already there." });
}
}
await client.query("commit");
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
return { moved, failed };
}
export async function createAssetFolder(input: { parentId: string | null; name: string }) {
const { rows } = await pool.query(
`insert into assets (parent_id, kind, name) values ($1, 'folder', $2) returning ${COLUMNS}`,
[input.parentId, input.name]
);
return rows[0];
}Wire them next to your other actions:
getTableActions: () => ({
list: listAssets,
create: createAsset,
update: updateAsset,
delete: deleteAsset, // refuse a folder that is not empty, or delete its content
tree: { path: assetPath, move: moveAssets, createFolder: createAssetFolder },
}),Host hooks
table.filetree also takes runtime callbacks for your own kinds, icons, details and permissions. They are never saved in views.
filetree: {
parentColumn: "parentId",
// A custom kind: records of type "album" are folders.
isFolder: (row) => row.type === "album",
// A kind ("image", "video", "audio", "document", "archive", "code", "file",
// "folder", "folder-open") or an image.
getIcon: (row) => (row.type === "album" ? { src: "/icons/album.svg", alt: "" } : undefined),
// Interface permissions; the server still decides.
canMove: (row, target) => row.locked !== true && target?.readOnly !== true,
canCreateFolder: (parent) => parent?.readOnly !== true,
canRename: (row) => row.locked !== true,
// Files dropped from the desktop onto a folder (null is the root).
onDropFiles: async ({ parentId, files }) => {
await uploadAssets(parentId, files); // your uploader
},
},canMove(row, target)returningfalsedisables the drop and the Move to… target with "You don't have permission to move here".targetisnullfor the root.onDropFiles({ parentId, files })is called when files from the desktop are dropped onto a folder. The view shows "Uploading N files…" and reloads the folder. The table ships no uploader: without this hook, desktop drops do nothing.renderDetails(row)replaces the body of the details pane: a React node, or a Vue render result.
filetree: {
showDetails: true,
renderDetails: (row) => <AssetDetails asset={row} />,
},import { h } from "vue";
filetree: {
showDetails: true,
renderDetails: (row) => h(AssetDetails, { asset: row }),
},Translations
The file tree has built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat filetree.<key> keys in the table's translations, in React and Vue, for example "filetree.newFolder": "New album". The complete key list is in Translations; the folder dialog and picker of the other views add rootFolder, parentFolder, inFolder, noFolders and createFailed. Some keys take values: selected, showMore, folders, files, items, loaded, moveFailed, cutReady, deleteTitle, deleted, moveDialogTitle, truncated, expandedFirst and uploading with {count}; moved and moveItems with {count} and {path}; movedOne and moveItem with {name} and {path}; created, renamed, deleteOneTitle, moveOneDialogTitle and sortBy with {name}.
The mode's name in the display mode picker is views.display.filetree in React and display.filetree in Vue.
Compatibility notes
TableDisplayMode now includes "filetree". If your code keeps an exhaustive Record<TableDisplayMode, …> map, such as icons or labels per mode, add a filetree entry so it keeps compiling:
const modeLabels: Record<TableDisplayMode, string> = {
// ...
filetree: "File tree",
};TableActions gains the optional tree actions and typed list meta fields (scope, childCounts, sizes, ancestors, truncated). Renderer contexts gain title, tree, patchRow, deleteRow, canDeleteRow, media, imageColumn, selection, syncUrl and refresh, which custom display mode renderers can use the same way.