Yayaw
Documentation
Vues

Vue Arborescence

Afficher les fiches comme des dossiers et des fichiers, avec chargement serveur à la demande, glisser-déposer, Déplacer vers…, annulation et volet de détails.

La vue Arborescence affiche les fiches d’une table comme des dossiers et des fichiers, comme la barre latérale du Finder, de Google Drive ou de VS Code. Elle est pensée pour les gestionnaires de ressources : à côté de la Galerie, qui montre les vignettes, et de la table, qui montre les métadonnées, on parcourt et on réorganise les mêmes ressources par dossier. React et Vue se comportent de la même façon.

La table gère l’arborescence et son interface. Votre application gère le stockage, la hiérarchie côté serveur, les permissions et les déplacements eux-mêmes.

Activer

L’Arborescence est livrée avec les éléments de la table et n’ajoute aucune dépendance. Les fiches sont reliées par une colonne parent qui contient l’id de leur dossier parent (vide pour les éléments à la racine). Ajoutez "filetree" à displayModes :

assets-config.ts
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: "Ressources",
    },
  },
});

Le mode est proposé quand table.filetree.parentColumn est défini, ou quand la table a une colonne nommée parentId, parent_id, parent, folderId, folder_id ou folder. table.filetree est optionnel : un objet de réglages par défaut (la forme ci-dessous) dont part chaque vue Arborescence, plus les hooks de l’hôte, ou false pour désactiver le mode. Avec false, "filetree" n’est pas proposé même s’il figure dans displayModes, et un lien qui le demande revient au mode par défaut. Un renderer passé dans displayModeRenderers.filetree remplace celui intégré.

Essayer l’arborescence

Les dossiers et fichiers d’une bibliothèque de marque vivent dans une même table, reliés par parentId. Utilisez Tout déplier et Tout replier, ouvrez un dossier avec son chevron ou les flèches du clavier, faites glisser un fichier sur un autre dossier ou utilisez Déplacer vers…, créez un dossier, et ouvrez Détails pour l’aperçu et les actions. Cet hôte en mémoire n’applique aucun scope : l’arborescence utilise donc les solutions de repli du navigateur. Passez le Mode d’affichage sur Galerie ou Table pour voir les mêmes enregistrements. Les dossiers et fichiers sont communs à React et Vue, et l’hôte est le createMemoryActions des données des exemples.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Dossiers et fichiers

Chaque fiche est un nœud. Une fiche est un dossier quand sa colonne de type contient "folder" ; sans colonne de type, quand elle a des enfants ou quand le hook isFolder renvoie vrai. Tout le reste est un fichier.

La vue est une table avec l’arborescence dans sa colonne Nom (le modèle WAI-ARIA treegrid) :

  • En-tête : le libellé de la racine (le nom de la table par défaut), un résumé des nœuds connus comme « 6 dossiers · 15 fichiers », les actions de la vue Nouveau dossier, Tout déplier, Tout replier et Détails, et le fil d’Ariane du dossier courant.

  • Lignes : une case à cocher de sélection, puis le nom avec son retrait (il cesse de grandir après 8 niveaux, le fil d’Ariane montre le chemin complet), un chevron pour les dossiers, une icône colorée (dossier ouvert ou fermé, ou le type de fichier : image, vidéo, audio, PDF ou document, archive, code, fichier générique) et le nombre d’enfants quand il est connu. Viennent ensuite les autres colonnes de la vue, masquées sur les écrans étroits, et un menu de ligne.

  • Taille est alignée à droite en unités décimales (1.2 MB). Les dossiers affichent -- sauf si votre serveur renvoie leurs totaux. Modifié est une date relative (« il y a 2 heures », « Hier »).

  • Les en-têtes de colonnes trient l’arborescence. Les dossiers restent en premier dans chaque dossier, et les noms suivent un ordre naturel qui tient compte de la langue (file2 avant file10).

Les icônes et aperçus des fichiers reprennent les réglages de médias de la galerie (table.gallery.media : urlColumn, mimeTypeColumn, getMedia), puis le type MIME ou l’extension du fichier.

Quand plusieurs éléments sont sélectionnés, une barre flottante apparaît sous l’arborescence sans déplacer les lignes : « 3 sélectionnés · Déplacer vers… · Supprimer · Effacer la sélection ». L’arborescence garde sa propre sélection, qui s’étend à des dossiers qui ne sont pas chargés sur la même page. La pagination par pages de la table est masquée dans ce mode, puisque l’arborescence pagine elle-même chaque dossier.

Volet de détails

Le volet de détails est optionnel (showDetails, désactivé par défaut). Il s’ouvre depuis Détails dans l’en-tête ou Infos dans un menu de ligne, et se redimensionne en faisant glisser son bord ou avec les flèches du clavier sur ce bord. Il affiche un aperçu (la vignette du média, qui ouvre la visionneuse native, ou une grande icône), le nom, la nature, l’emplacement, le nombre d’éléments, les colonnes de la vue et detailFields, ainsi que les actions Aperçu, Ouvrir, Renommer, Déplacer vers…, Nouveau dossier et Supprimer. renderDetails remplace son contenu.

Tout déplier et tout replier

Tout déplier ouvre tous les dossiers, pas seulement ceux déjà chargés. Avec le scope subtree, toute l’arborescence arrive en une requête. Sans lui, les dossiers sont chargés en largeur, niveau par niveau, jusqu’à 2 000 éléments, et la vue indique « Les 2000 premiers éléments ont été dépliés » quand elle s’arrête là. Tout replier ferme tous les dossiers. Le choix est enregistré avec la vue (expandedAll).

Clavier

L’arborescence est un seul arrêt de tabulation, avec un focus itinérant sur ses lignes ; les contrôles à l’intérieur des lignes ont des équivalents clavier.

TouchesAction
↑ ↓, Début, FinDéplacer le focus.
→ ←Déplier ou replier. → sur un dossier ouvert va à son premier enfant, ← sur un enfant va à son parent.
Entrée, double clicDossier : déplier ou replier. Fichier : ouvrir l’aperçu du média, ou la vue de la fiche pour les autres fichiers, selon les réglages du clic sur une ligne.
Espace, Maj+↑↓, Maj/Cmd-clic, Ctrl/Cmd+ABasculer, étendre ou tout sélectionner.
*Déplier tous les dossiers au même niveau que la ligne active.
Alt+Maj+↓, Alt+Maj+↑Tout déplier, Tout replier (annoncés avec aria-keyshortcuts sur les boutons).
F2Renommer sur place.
Ctrl/Cmd+X, puis Ctrl/Cmd+V sur une ligneDéplacer les éléments coupés dans ce dossier, ou dans le dossier du fichier. Échap annule.
Suppr, Retour arrièreSupprimer, après confirmation.
Maj+F10, touche de menu contextuelOuvrir le menu de la ligne.
Ctrl/Cmd+ZAnnuler le dernier déplacement.
LettresSaisie rapide : aller à l’élément visible suivant qui commence par les lettres tapées.

Les chargements, déplacements, renommages et nouveaux dossiers sont annoncés dans une région live polie. aria-level, aria-setsize et aria-posinset viennent de l’arborescence, et restent donc justes quand les longues listes sont virtualisées.

Déplacer des éléments

  • Glisser-déposer : faites glisser des lignes sur un dossier. La cible est mise en évidence, un dossier s’ouvre après 600 ms de survol, la liste défile près de ses bords, et une étiquette suit le pointeur. Déposer sur un fichier déplace dans le dossier de ce fichier ; déposer sur l’en-tête déplace à la racine.

  • Les cibles invalides affichent un curseur d’interdiction et la raison : « Impossible de déplacer un dossier dans lui-même », « Impossible de déplacer un dossier dans l’un de ses sous-dossiers », « Impossible de déplacer des éléments dans Non classés », « Vous n’avez pas le droit de déplacer ici » (depuis canMove) et « Déjà dans ce dossier ».

  • Déplacer vers…, dans le menu de ligne, le volet de détails et la barre de sélection, ouvre un sélecteur de dossier : une arborescence avec recherche où les cibles invalides sont désactivées avec leur raison. Il fonctionne partout, y compris sur téléphone et au clavier.

  • Couper et coller : Ctrl/Cmd+X puis Ctrl/Cmd+V, comme dans le tableau du clavier.

Les déplacements sont optimistes : l’arborescence change tout de suite, puis appelle votre serveur. Les éléments refusés par votre serveur reviennent à leur place, et la première erreur est affichée. La notification propose Annuler (« 3 éléments déplacés vers Brand › 2026 · Annuler »), et Ctrl/Cmd+Z annule le dernier déplacement.

Nouveau dossier ajoute un champ de nom dans le dossier actif (ou le dossier du fichier actif, la racine quand rien n’a le focus, le dossier ouvert sur téléphone) ; un nom vide devient « Nouveau dossier ». F2 ou Renommer modifie le nom sur place (une erreur du serveur, comme un nom déjà pris, reste sur la ligne), et Supprimer demande d’abord une confirmation. Quand un dossier est supprimé, votre serveur décide de la suite : supprimer son contenu, refuser tant qu’il n’est pas vide, ou remonter ses enfants. La vue affiche votre message.

Téléphones

Sur téléphone, il n’y a pas de volet de détails. Toucher un dossier y entre, avec le fil d’Ariane et un bouton Retour ; toucher un fichier ouvre son aperçu ou sa fiche. Les cases à cocher restent visibles pour la sélection. Les déplacements passent par Déplacer vers…, dont le sélecteur de dossier devient un panneau en bas de l’écran ; le glisser au doigt est désactivé.

Les dossiers dans les autres vues

Une table dont les lignes forment une arborescence propose aussi ses dossiers dans ses autres vues (table, liste, galerie, Kanban, calendrier, carte), pour qu’on n’ait pas à passer par l’arborescence :

  • Nouveau dossier dans la barre d’outils (pas dans l’arborescence, qui a le sien, ni dans la vue Formulaire ou le Gantt), quand des dossiers peuvent être créés : avec tree.createFolder, sinon create avec allowCreate. Une fenêtre demande le nom et le dossier parent, dans un sélecteur avec recherche qui liste d’abord la racine puis chaque dossier avec son emplacement. Il part du dossier sur lequel la vue est filtrée (un seul dossier), sinon de la racine. Le nom suit les règles de l’arborescence (espaces retirés ; vide, il devient « Nouveau dossier »), canCreateFolder(parent) est consulté, l’erreur de votre serveur (un nom déjà pris, par exemple) s’affiche dans la fenêtre, et la table se recharge ensuite.

  • Le filtre par dossier : la colonne parente se filtre avec le même sélecteur dans les menus de filtres ; voir Filtrer par dossier. Une facette sur la colonne parente liste les mêmes dossiers avec leur nombre d’enregistrements.

table.filetree.newFolderAction: false et table.filetree.folderFilter: false les désactivent. Les dossiers se chargent une fois, quand un sélecteur ou une facette en a besoin, puis de nouveau après un changement des données de la table : list avec scope: { kind: "subtree", parentId: null } et, avec une colonne de type, une règle qui garde les dossiers, jusqu’à 2 000. Un serveur qui n’applique pas cette portée reçoit le chargement plafonné de toutes les lignes, et les dossiers sont pris parmi elles.

Recherche et filtres

Quand la vue a une recherche ou des filtres, l’arborescence montre les éléments correspondants avec leurs dossiers ancêtres dépliés et les correspondances mises en évidence, et masque les autres éléments du même niveau. Les correspondances viennent du scope tree-matches, ou sinon de la requête de liste, avec les ancêtres pris dans les lignes chargées sans elle (les deux plafonnés, avec un avis quand une partie seulement est affichée).

Réglages de l’arborescence

Choisissez Arborescence dans Réglages de la vue › Mode d’affichage, puis ouvrez Réglages des cartes dans le même menu. On y trouve les colonnes parent, nom, type, taille et modification, un préréglage de colonnes (taille et modification, taille, modification, nom seulement), le volet de détails, les dossiers en premier et la profondeur ouverte au premier chargement. Un clic sur un en-tête de colonne règle le tri.

Chaque vue enregistre ses réglages (config.filetree), également conservés dans la clé d’URL <tableId>-filetree. Le dossier dans lequel vous êtes est conservé dans <tableId>-folder, si bien qu’un lien ouvre le même dossier, par exemple ?assets-display=filetree&assets-folder=campaigns-2026.

RéglagePar défautDescription
parentColumnDétectée d’après les noms de colonnes ci-dessusId du dossier parent ; vide pour les éléments à la racine.
kindColumnAucune"folder" ou "file". Sans elle, une fiche qui a des enfants, ou pour laquelle isFolder renvoie vrai, est un dossier.
nameColumnUne colonne nommée name, title, filename ou label, sinon la première colonne texteLe libellé du nœud.
sizeColumnAucuneTailles en octets.
updatedColumnAucuneDate de dernière modification, affichée en relatif.
columnsLes colonnes de taille et de modificationIds des colonnes affichées après Nom.
showDetailsfalseAfficher le volet de détails (ordinateur).
detailFields[]Colonnes supplémentaires listées dans le volet de détails.
foldersFirsttrueDossiers avant les fichiers dans chaque dossier.
sortLe tri de la vue, puis le nom{ id, desc? }, réglé par un clic sur un en-tête de colonne.
defaultExpandedDepth1Niveaux de dossiers ouverts au premier chargement : 0, 1 (dossiers de premier niveau ouverts) ou 2.
rootLabelLe nom de la table, sinon « Tous les fichiers »Libellé de la racine dans l’en-tête et le fil d’Ariane.
expanded[]Ids des dossiers ouverts, enregistrés avec la vue (500 au plus).
expandedAllfalseTous les dossiers ouverts, après Tout déplier.

Les réglages de la vue l’emportent sur table.filetree, qui l’emporte sur ces valeurs par défaut. Les hooks de l’hôte sont des callbacks d’exécution et ne sont jamais enregistrés dans les vues ni les URL. table.filetree accepte aussi newFolderAction et folderFilter (tous deux true par défaut), qui s’appliquent aux autres vues et ne sont pas des réglages de vue.

Charger depuis votre serveur

Une arborescence de 100 000 ressources ne doit pas se charger d’un coup : l’arborescence demande donc à actions.list un dossier à la fois. Elle ajoute un scope aux paramètres habituels, comme la plage de dates du calendrier, et votre serveur confirme qu’il a appliqué le scope avec meta.scope: "applied". Chaque scope est additif : un serveur qui l’ignore fonctionne quand même grâce aux solutions de repli.

Enfants

list({ ...query, scope: { kind: "children", parentId: "brand" }, page: 1, pageSize: 200 })
// → { data, meta: { scope: "applied", totalCount, childCounts?, sizes? } }

parentId: null désigne la racine. La requête porte le tri de la vue, mais pas sa recherche ni ses filtres. Répondez avec les enfants directs du dossier, une page à la fois (200 par page), les dossiers d’abord puis dans l’ordre demandé, avec :

  • meta.totalCount : le nombre d’enfants du dossier. Un dossier de plus de 200 éléments affiche Afficher plus (N).

  • meta.childCounts (optionnel) : { [folderId]: number } pour les dossiers de data, pour que la vue affiche les chevrons et les nombres sans charger les petits-enfants. Sans lui, chaque dossier a un chevron qui peut se révéler vide.

  • meta.sizes (optionnel) : { [folderId]: octets }, les totaux affichés dans la colonne Taille pour les dossiers.

Les dossiers chargés sont mis en cache, et rechargés après les déplacements, nouveaux dossiers, renommages, suppressions, modifications de la table et Actualiser. Une nouvelle requête ou de nouveaux réglages de colonnes vident le cache.

Sous-arbre (Tout déplier)

list({ ...query, scope: { kind: "subtree", parentId: null }, pageSize: 2000 })
// → { data: tous les descendants, meta: { scope: "applied", truncated?, childCounts?, sizes? } }

Répondez avec tous les descendants de parentId. Vous pouvez plafonner la réponse ; mettez alors meta.truncated: true, et la vue le signale.

Recherche (correspondances)

list({ ...query, scope: { kind: "tree-matches" } })
// → { data: correspondances, meta: { scope: "applied", ancestors: Row[], truncated? } }

Envoyé quand la vue a une recherche ou des filtres, avec eux. Répondez avec les fiches correspondantes dans data, et dans meta.ancestors leurs dossiers ancêtres qui ne correspondent pas eux-mêmes.

Chemin, déplacement et nouveau dossier

Trois actions optionnelles se trouvent sous actions.tree (voir API des actions) :

ActionSignatureSans elle
Chemintree.path(id) → Row[] : les ancêtres d’un nœud, racine d’abord, pour le fil d’Ariane et les liens vers un dossier.La colonne parent est parcourue sur les lignes chargées.
Déplacementtree.move({ ids, parentId }) → { moved?, failed? } : un seul lot ; failed vaut [{ id, error? }].update(id, { [parentColumn]: parentId }) pour chaque fiche.
Nouveau dossiertree.createFolder({ parentId, name }) → Rowcreate avec le nom, le parent et kindColumn: "folder".

Les renommages passent par update de la colonne du nom, et les suppressions par delete, une fiche à la fois. Votre serveur revérifie tout : permissions, cycles et noms déjà pris. Les hooks de l’interface masquent seulement ce que l’utilisateur ne peut pas faire.

Solutions de repli

Quand votre list ne répond pas meta.scope: "applied", la vue charge les lignes avec le chargeur plafonné de toutes les lignes de la table (2 000 lignes, avec l’avis habituel quand il y en a plus) et construit l’arborescence dans le navigateur. Une fiche dont le parent est inconnu, ou dont le parent créerait un cycle, apparaît alors dans un dossier virtuel Non classés au lieu d’être perdue. Avec le scope children, le placement de ces fiches est le choix de votre serveur.

Exemple côté serveur

Cet exemple PostgreSQL range les ressources dans une seule table avec une colonne parent_id. La requête des enfants renvoie le nombre d’enfants et la taille des dossiers, des requêtes récursives répondent au sous-arbre, aux ancêtres de la recherche et au chemin, et le déplacement vérifie les cycles et les noms déjà pris dans une seule transaction. Ajoutez vos filtres d’organisation et de permissions à chaque requête, et vérifiez la session dans chaque server action.

assets.sql
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);
-- Un nom par dossier, racine comprise (PostgreSQL 15+).
create unique index assets_name_per_folder
  on assets (parent_id, lower(name)) nulls not distinct;
server/assets-tree.ts
"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" };

/** Dossiers d’abord, puis le tri de la vue depuis une liste autorisée (jamais la saisie brute). */
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`;
}

/** Nombre d’enfants et taille totale des dossiers parmi `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) {
  // En largeur, pour qu’une réponse plafonnée garde le haut de l’arborescence.
  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 -- et vos filtres de list
      order by lower(name) limit $2`,
    [search, MAX_SUBTREE]
  );
  const ids = matches.rows.map((row) => row.id);
  // « union » (et non « union all ») s’arrête sur les dossiers déjà visités.
  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); // votre liste paginée habituelle pour Galerie et Table
}

/** Ancêtres d’un nœud, racine d’abord. */
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");
    // Une modification de l’arborescence à la fois : deux déplacements simultanés ne peuvent pas former un cycle.
    await client.query("select pg_advisory_xact_lock(hashtext('assets-tree'))");
    // La cible et ses ancêtres : déplacer l’un d’eux dans la cible créerait un 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("Les éléments ne peuvent aller que dans des dossiers.");
      }
      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: "Impossible de déplacer un dossier dans lui-même ou dans l’un de ses sous-dossiers." });
        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: "Un élément de ce nom s’y trouve déjà." });
      }
    }
    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];
}

Branchez-les à côté de vos autres actions :

assets-actions.ts
getTableActions: () => ({
  list: listAssets,
  create: createAsset,
  update: updateAsset,
  delete: deleteAsset, // refuser un dossier non vide, ou supprimer son contenu
  tree: { path: assetPath, move: moveAssets, createFolder: createAssetFolder },
}),

Hooks de l’hôte

table.filetree accepte aussi des callbacks d’exécution pour vos propres types, icônes, détails et permissions. Ils ne sont jamais enregistrés dans les vues.

filetree: {
  parentColumn: "parentId",
  // Un type personnalisé : les fiches de type "album" sont des dossiers.
  isFolder: (row) => row.type === "album",
  // Un type ("image", "video", "audio", "document", "archive", "code", "file",
  // "folder", "folder-open") ou une image.
  getIcon: (row) => (row.type === "album" ? { src: "/icons/album.svg", alt: "" } : undefined),
  // Permissions de l’interface ; le serveur décide toujours.
  canMove: (row, target) => row.locked !== true && target?.readOnly !== true,
  canCreateFolder: (parent) => parent?.readOnly !== true,
  canRename: (row) => row.locked !== true,
  // Fichiers déposés depuis le bureau sur un dossier (null pour la racine).
  onDropFiles: async ({ parentId, files }) => {
    await uploadAssets(parentId, files); // votre outil d’envoi
  },
},
  • canMove(row, target) qui renvoie false désactive le dépôt et la cible de Déplacer vers… avec « Vous n’avez pas le droit de déplacer ici ». target vaut null pour la racine.

  • onDropFiles({ parentId, files }) est appelé quand des fichiers du bureau sont déposés sur un dossier. La vue affiche « Envoi de N fichiers… » et recharge le dossier. La table ne fournit pas d’outil d’envoi : sans ce hook, les dépôts depuis le bureau n’ont aucun effet.

  • renderDetails(row) remplace le contenu du volet de détails : un nœud React, ou un résultat de rendu Vue.

assets-details.tsx
filetree: {
  showDetails: true,
  renderDetails: (row) => <AssetDetails asset={row} />,
},
assets-details.ts
import { h } from "vue";

filetree: {
  showDetails: true,
  renderDetails: (row) => h(AssetDetails, { asset: row }),
},

Traductions

L’arborescence a des libellés anglais et français intégrés ; le français est utilisé quand la locale commence par fr. Remplacez-en n’importe lequel avec des clés plates filetree.<clé> dans les traductions de la table, en React comme en Vue, par exemple "filetree.newFolder": "Nouvel album". La liste complète des clés se trouve dans Traductions ; la fenêtre et le sélecteur de dossiers des autres vues ajoutent rootFolder, parentFolder, inFolder, noFolders et createFailed. Certaines clés prennent des valeurs : selected, showMore, folders, files, items, loaded, moveFailed, cutReady, deleteTitle, deleted, moveDialogTitle, truncated, expandedFirst et uploading avec {count} ; moved et moveItems avec {count} et {path} ; movedOne et moveItem avec {name} et {path} ; created, renamed, deleteOneTitle, moveOneDialogTitle et sortBy avec {name}.

Le nom du mode dans le sélecteur de mode d’affichage est views.display.filetree en React et display.filetree en Vue.

Notes de compatibilité

TableDisplayMode inclut désormais "filetree". Si votre code garde une table exhaustive Record<TableDisplayMode, …>, comme des icônes ou des libellés par mode, ajoutez une entrée filetree pour qu’il compile toujours :

const modeLabels: Record<TableDisplayMode, string> = {
  // ...
  filetree: "Arborescence",
};

TableActions gagne les actions optionnelles tree et des champs meta de liste typés (scope, childCounts, sizes, ancestors, truncated). Les contextes des renderers gagnent title, tree, patchRow, deleteRow, canDeleteRow, media, imageColumn, selection, syncUrl et refresh, que les renderers de mode d’affichage personnalisés peuvent utiliser de la même façon.