Skip to content
Merged
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
7 changes: 3 additions & 4 deletions apps/docs/app/trees-dev/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';

import { readSettingsCookies } from './_components/readSettingsCookies';
Expand All @@ -10,9 +9,9 @@ export default async function TreesDevLayout({
}: {
children: ReactNode;
}) {
if (process.env.NODE_ENV !== 'development') {
return notFound();
}
// if (process.env.NODE_ENV !== 'development') {
// return notFound();
// }

const { flattenEmptyDirectories, useLazyDataLoader } =
await readSettingsCookies();
Expand Down
115 changes: 71 additions & 44 deletions apps/docs/public/trees/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ folder containing `index.ts`, `utils/helpers.ts`, and a `components` folder with
| `lockedPaths` | Optional list of file/folder paths that cannot be dragged when drag and drop is enabled. |
| `onCollision` | Optional callback for drag collisions. Return `true` to overwrite destination. |
| `gitStatus` | Optional `GitStatusEntry[]` used to show Git-style file status (`added`, `modified`, `deleted`). Folders with changed descendants also receive a change indicator. [Live demo](/preview/trees#path-colors). |
| `icons` | Optional `FileTreeIconConfig` to provide a custom SVG sprite sheet and remap built-in icon names to your own symbols. [Live demo](/preview/trees#custom-icons). |
| `icons` | Optional built-in icon set selection or `FileTreeIconConfig` for semantic colors, CSS-themable palettes, and custom sprite overrides. [Live demo](/preview/trees#custom-icons). |
| `sort` | Sort children within each directory. `true` (default) uses the standard sort (folders first, dot-prefixed next, case-insensitive alphabetical). `false` preserves insertion order. `{ comparator: fn }` for custom sorting. |
| `virtualize` | Enable virtualized rendering so only visible items are rendered. Pass `{ threshold: number }` to activate when item count exceeds the threshold, or `false` to disable. Default: `undefined` (off). |

Expand Down Expand Up @@ -174,8 +174,15 @@ const fileTreeOptions = {
```typescript
import type { FileTreeIconConfig } from '@pierre/trees';

// FileTreeIconConfig lets you replace built-in icons with custom SVG symbols.
// FileTreeIconConfig lets you pick a built-in set, enable semantic colors,
// or inject your own SVG symbols.
interface FileTreeIconConfig {
// Optional: use one of the built-in sets, or "none" for custom-only rules.
set?: 'minimal' | 'standard' | 'complete' | 'none';

// Optional: enable built-in per-file-type colors. Default: true.
colored?: boolean;

// An SVG string with <symbol> definitions injected into the shadow DOM.
spriteSheet?: string;

Expand All @@ -185,12 +192,35 @@ interface FileTreeIconConfig {
| string
| { name: string; width?: number; height?: number; viewBox?: string }
>;

// Remap file icons by exact basename (e.g. package.json, .gitignore).
byFileName?: Record<
string,
| string
| { name: string; width?: number; height?: number; viewBox?: string }
>;

// Remap file icons by extension (e.g. ts, tsx, spec.ts).
byFileExtension?: Record<
string,
| string
| { name: string; width?: number; height?: number; viewBox?: string }
>;

// Remap file icons when filename contains a substring (e.g. dockerfile).
byFileNameContains?: Record<
string,
| string
| { name: string; width?: number; height?: number; viewBox?: string }
>;
}

// Example: replace the file and chevron icons with custom symbols.
// Example: use the built-in file-type set with colors enabled, then override one icon.
const options = {
initialFiles: ['src/index.ts', 'src/components/Button.tsx'],
icons: {
set: 'standard',
colored: true,
spriteSheet: `
<svg data-icon-sprite aria-hidden="true" width="0" height="0">
<symbol id="my-file" viewBox="0 0 24 24" fill="none"
Expand All @@ -204,8 +234,10 @@ const options = {
</symbol>
</svg>
`,
byFileExtension: {
ts: 'my-file',
},
remap: {
'file-tree-icon-file': 'my-file',
'file-tree-icon-chevron': { name: 'my-folder', width: 16, height: 16 },
},
},
Expand All @@ -217,7 +249,7 @@ const options = {
```typescript
import type {
FileTreeOptions,
FileTreeIconConfig,
FileTreeIcons,
FileTreeStateConfig,
FileTreeSearchMode,
FileTreeCollision,
Expand Down Expand Up @@ -252,8 +284,8 @@ interface FileTreeOptions {
// Optional: Git status entries for file status indicators.
gitStatus?: GitStatusEntry[];

// Optional: custom SVG sprite sheet and icon remapping.
icons?: FileTreeIconConfig;
// Optional: built-in icon set selection, colors, and custom remapping.
icons?: FileTreeIcons;

// Optional: paths that cannot be dragged when drag and drop is enabled.
lockedPaths?: string[];
Expand Down Expand Up @@ -433,26 +465,14 @@ React component:
```tsx
import { FileTree } from '@pierre/trees/react';

const customSpriteSheet = `
<svg data-icon-sprite aria-hidden="true" width="0" height="0">
<symbol id="my-file" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
</symbol>
</svg>
`;

export function CustomIconsTree() {
export function IconSetTree() {
return (
<FileTree
options={{
id: 'custom-icons-tree',
id: 'icon-set-tree',
icons: {
spriteSheet: customSpriteSheet,
remap: {
'file-tree-icon-file': 'my-file',
},
set: 'standard',
colored: true,
},
}}
initialFiles={[
Expand Down Expand Up @@ -623,27 +643,15 @@ constructor's second argument. Use it for defaults (`initialExpandedItems`,
```typescript
import { FileTree } from '@pierre/trees';

const customSpriteSheet = `
<svg data-icon-sprite aria-hidden="true" width="0" height="0">
<symbol id="my-file" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
</symbol>
</svg>
`;

const fileTree = new FileTree({
initialFiles: [
'src/index.ts',
'src/components/Button.tsx',
'package.json',
],
icons: {
spriteSheet: customSpriteSheet,
remap: {
'file-tree-icon-file': 'my-file',
},
set: 'standard',
colored: true,
},
});

Expand Down Expand Up @@ -788,19 +796,38 @@ target:
- `data-item-git-status="added" | "modified" | "deleted"` on changed files
- `data-item-contains-git-change="true"` on folders that contain changed files

## Custom Icons
## Icons

Use the `icons` option inside `FileTreeOptions` to swap built-in icons with your
own SVG symbols. Try the live demo at
Use the `icons` option inside `FileTreeOptions` to choose one of the built-in
icon sets or inject your own SVG sprite. Try the live demo at
[/preview/trees#custom-icons](/preview/trees#custom-icons).

- `icons: 'minimal' | 'standard' | 'complete'` — use one of the shipped icon
tiers. Each tier is cumulative: `standard` includes everything in `minimal`
plus language icons, and `complete` adds brands and tooling on top.
- `set` — use the object form to combine a built-in set with `colored`,
`spriteSheet`, or file-specific overrides.
- `colored` — semantic per-file-type colors for built-in `standard` and
`complete` icons. Defaults to `true`; set `colored: false` to disable it.
Override the palette with CSS variables like
`--trees-file-icon-color-javascript`.
- `spriteSheet` — an SVG string containing `<symbol>` definitions. It is
injected into the shadow DOM alongside the default sprite sheet.
injected into the shadow DOM alongside the selected built-in sprite sheet.
- `remap` — a map from a built-in icon name to either a replacement symbol id
(string) or an object with `name`, optional `width`, `height`, and `viewBox`.

You can re-map any of the existing, default icons (listed below) by creating new
SVG symbols that use the same IDs.
- `byFileName` — remap the file icon for exact basenames (for example
`package.json` or `.gitignore`).
- `byFileNameContains` — remap the file icon when a basename contains a pattern
(for example `dockerfile` or `license`).
- `byFileExtension` — remap the file icon by extension (for example `ts`, `tsx`,
`spec.ts`, or `json`).

You can remap any of the existing built-in icon slots (listed below) by creating
new SVG symbols that use the same IDs.

For file rows, icon resolution order is: `byFileName` → `byFileNameContains` →
`byFileExtension` (most specific suffix first) → built-in set mapping →
`remap['file-tree-icon-file']` → fallback file icon.

| Icon ID | Description |
| ------------------------ | ------------------------------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/public/trees/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- [React API](https://diffs.com/preview/trees/docs#react-api): FileTree React component and props
- [Vanilla JS API](https://diffs.com/preview/trees/docs#vanilla-js-api): FileTree class, constructor options, instance methods, and FileTreeStateConfig
- [Git Status](https://diffs.com/preview/trees/docs#git-status): Git-style file status indicators
- [Custom Icons](https://diffs.com/preview/trees/docs#custom-icons): Custom SVG sprite sheets and icon remapping
- [Icons](https://diffs.com/preview/trees/docs#icons): Custom SVG sprite sheets and icon remapping
- [Utilities](https://diffs.com/preview/trees/docs#utilities): sortChildren, generateSyncDataLoader, generateLazyDataLoader
- [Styling](https://diffs.com/preview/trees/docs#styling): CSS variables and inline style overrides
- [SSR](https://diffs.com/preview/trees/docs#ssr): preloadFileTree for server-side rendering and vanilla hydration
Expand Down
18 changes: 8 additions & 10 deletions packages/path-store/src/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ function buildVisibleTreeProjectionDataDFS(
const paths = new Array<string>(maxRows);
const parentRowIndex = new Int32Array(maxRows);
const posInSetByIndex = new Int32Array(maxRows);
const childCount = new Int32Array(maxRows + 1);
const setSizeByIndex = new Int32Array(maxRows);
let lastRowAtDepth: ProjectionDepthTable = new Int32Array(
INITIAL_PROJECTION_DEPTH_CAPACITY
);
Expand All @@ -502,6 +502,7 @@ function buildVisibleTreeProjectionDataDFS(
continue;
}

const childOffset = frame[1];
const childId = dirIndex.childIds[frame[1]++];
const childNode = nodes[childId];
const visibleDepth = frame[2] + 1;
Expand Down Expand Up @@ -531,10 +532,11 @@ function buildVisibleTreeProjectionDataDFS(

const parentIdx = lastRowAtDepth[visibleDepth];
parentRowIndex[rowCount] = parentIdx;
const countSlot = parentIdx + 1;
childCount[countSlot] += 1;
paths[rowCount] = path;
posInSetByIndex[rowCount] = childCount[countSlot] - 1;
posInSetByIndex[rowCount] = childOffset;
// The current frame iterates the full child array for the row's parent, so
// childIds.length stays correct even when we cap the emitted projection.
setSizeByIndex[rowCount] = dirIndex.childIds.length;
lastRowAtDepth[visibleDepth + 1] = rowCount;

rowCount += 1;
Expand All @@ -553,13 +555,9 @@ function buildVisibleTreeProjectionDataDFS(
paths.length = rowCount;
}

const setSizeByIndex = new Int32Array(rowCount);
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
setSizeByIndex[rowIndex] = childCount[parentRowIndex[rowIndex] + 1] ?? 0;
}

const finalParentRowIndex = parentRowIndex.subarray(0, rowCount);
const finalPosInSetByIndex = posInSetByIndex.subarray(0, rowCount);
const finalSetSizeByIndex = setSizeByIndex.subarray(0, rowCount);
let cachedVisibleIndexByPath: Map<string, number> | null = null;
return {
getParentIndex(index: number): number {
Expand All @@ -569,7 +567,7 @@ function buildVisibleTreeProjectionDataDFS(
},
paths,
posInSetByIndex: finalPosInSetByIndex,
setSizeByIndex,
setSizeByIndex: finalSetSizeByIndex,
get visibleIndexByPath(): Map<string, number> {
if (cachedVisibleIndexByPath == null) {
cachedVisibleIndexByPath = new Map<string, number>();
Expand Down
7 changes: 7 additions & 0 deletions packages/path-store/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,13 @@ export class PathStore {
previousChildOffsets.length = resolvedDepth;
previousNodeIds.length = resolvedDepth;
if (!foundDirectory) {
// A missing or non-directory lookup path must not leave behind a shared
// prefix cache for the next entry. Otherwise the next valid path can
// inherit an ancestor depth that was never actually expanded.
previousPath = null;
previousEndIndex = 0;
previousChildOffsets.length = 0;
previousNodeIds.length = 0;
continue;
}

Expand Down
39 changes: 39 additions & 0 deletions packages/path-store/test/path-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,26 @@ describe('PathStore', () => {
]);
});

test('capped visible tree projection data keeps full sibling counts', () => {
const store = new PathStore({
flattenEmptyDirectories: false,
initialExpansion: 'open',
paths: createWideDirectoryPaths(1000),
});

const projection = store.getVisibleTreeProjectionData(512);

expect(projection.paths.length).toBe(512);
expect(projection.paths[0]).toBe('wide/');
expect(projection.setSizeByIndex[0]).toBe(1);
expect(projection.paths[1]).toBe('wide/item1.ts');
expect(projection.posInSetByIndex[1]).toBe(0);
expect(projection.setSizeByIndex[1]).toBe(1000);
expect(projection.paths[511]).toBe('wide/item511.ts');
expect(projection.posInSetByIndex[511]).toBe(510);
expect(projection.setSizeByIndex[511]).toBe(1000);
});

test('supports visible tree projection depths beyond the initial typed-array capacity', () => {
const depth = 80;
const rows = Array.from({ length: depth }, (_, index) => ({
Expand Down Expand Up @@ -2150,6 +2170,25 @@ describe('PathStore', () => {
]);
});

test('ignores unresolved initialExpandedPaths entries without poisoning later valid prefixes', () => {
const store = new PathStore({
flattenEmptyDirectories: false,
initialExpandedPaths: ['a/b/abc.ts', 'a/cab/c'],
initialExpansion: 'closed',
paths: ['a/cab/c/c.ts'],
});

expect(store.isExpanded('a/')).toBe(true);
expect(store.isExpanded('a/cab/')).toBe(true);
expect(store.isExpanded('a/cab/c/')).toBe(true);
expect(getVisiblePaths(store, 0, 9)).toEqual([
'a/',
'a/cab/',
'a/cab/c/',
'a/cab/c/c.ts',
]);
});

test('path info resolves canonical directory lookups and initialExpandedPaths expands ancestors', () => {
const store = new PathStore({
flattenEmptyDirectories: false,
Expand Down
Loading
Loading