WorkspaceFile explorer
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callFile explorer
Project files, with the ones the agent touched marked. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add file-explorerReact
npx designme add file-explorer --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A project file tree that marks every file the agent changed with M, A or D for modified, added and deleted. Folders that hold a changed file get a dot, so people can find the agent's work without opening every folder. A filter box and an "only changed" switch narrow the tree down.
- When a coding or design agent edits files and people need to see which ones.
- Beside a diff or code viewer, to pick the file to review.
- When a project is large and people want to see only what the agent touched.
- Renders
treeas nested folders and files; a node withchildrenis a folder that opens and closes on click. - Badges each file by its
status('M', 'A' or 'D') and puts a dot on folders with a changed file inside. - The filter box keeps names that contain the typed text, along with the folders that lead to them, and opens those folders.
- The "Only changed" button hides every file the agent did not change, and the footer then reads how many files changed, worded by
summary. - Clicking a file selects it, shows its path in the footer and calls
onSelectedChange. - Picks a file icon from the extension, or from your own
fileIconfunction. - The tree scrolls inside the panel with a thin scrollbar in the style's line grey, square in Blueprint.
- The list has role="tree" and each row has role="treeitem", with aria-expanded on folders and aria-selected on files.
- Each folder's children sit in a role="group", which is inert while the folder is closed.
- The filter input is labelled "Filter files", and the "Only changed" button uses aria-pressed.
Usage
Import it and drive it from your own state.
Import
import { FileExplorer } from '@/components/designme/file-explorer/FileExplorer';Example
The demo above as a React component, 23 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: the tree opens on the file being worked on.
import { FileExplorer, type FileExplorerNode } from './FileExplorer';
const TREE: FileExplorerNode[] = [
{ name: 'src', children: [
{ name: 'components', children: [{ name: 'PlanCard.tsx', status: 'M' }, { name: 'PriceTag.tsx', status: 'A' }, { name: 'Button.tsx' }, { name: 'LegacyCTA.tsx', status: 'D' }] },
{ name: 'styles', children: [{ name: 'tokens.css', status: 'M' }, { name: 'globals.css' }] },
{ name: 'pages', children: [{ name: 'pricing.mdx', status: 'M' }, { name: 'index.mdx' }] }
] },
{ name: 'public', children: [{ name: 'og-pricing.png', status: 'A' }, { name: 'favicon.svg' }] },
{ name: 'package.json' }, { name: 'README.md' }
];
/* ---------- File explorer: the agent's changes marked, filter and "only changed" to narrow it down ---------- */
export default function FileExplorerDemo() {
return (
<div style={{ width: '100%' }}>
<FileExplorer tree={TREE} defaultOpen={['src', 'src/components', 'src/styles']} defaultSelected="src/components/PlanCard.tsx" />
</div>
);
}
Props
Everything FileExplorer takes.
| Prop | Type | What it does |
|---|---|---|
treerequired | FileExplorerNode[] | |
open | string[] | Paths of the open folders, like "src/components". Leave it out to let the folders open and close by themselves |
defaultOpen | string[] | |
onOpenChange | (paths: string[]) => void | |
selected | string | Path of the selected file |
defaultSelected | string | |
onSelectedChange | (path: string) => void | |
query | string | The filter text |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
onlyChanged | boolean | Shows only the files the agent changed |
defaultOnlyChanged | boolean | |
onOnlyChangedChange | (only: boolean) => void | |
summary | (changed: number) => string | The footer while only changed files show. Defaults to "6 files changed by the agent" |
fileIcon | (name: string) => IconName | The icon for a file. Defaults to one picked by its extension |
placeholder | string | |
className | string |
Code
The full source, as the CLI installs it.
FileExplorer.tsxReact · 115 lines
'use client';
import { Fragment, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './file-explorer.css';
/** How the agent changed a file: modified, added or deleted */
export type FileExplorerStatus = 'M' | 'A' | 'D';
/** A file, or a folder when it has children */
export interface FileExplorerNode {
name: string;
/** Marks a file the agent changed. Folders with a changed file inside get a dot */
status?: FileExplorerStatus;
children?: FileExplorerNode[];
}
export interface FileExplorerProps {
tree: FileExplorerNode[];
/** Paths of the open folders, like "src/components". Leave it out to let the folders open and close by themselves */
open?: string[];
defaultOpen?: string[];
onOpenChange?: (paths: string[]) => void;
/** Path of the selected file */
selected?: string;
defaultSelected?: string;
onSelectedChange?: (path: string) => void;
/** The filter text */
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
/** Shows only the files the agent changed */
onlyChanged?: boolean;
defaultOnlyChanged?: boolean;
onOnlyChangedChange?: (only: boolean) => void;
/** The footer while only changed files show. Defaults to "6 files changed by the agent" */
summary?: (changed: number) => string;
/** The icon for a file. Defaults to one picked by its extension */
fileIcon?: (name: string) => IconName;
placeholder?: string;
className?: string;
}
const changed = (n: FileExplorerNode): boolean => !!n.status || !!n.children?.some(changed);
const count = (ns: FileExplorerNode[]): number => ns.reduce((s, n) => s + (n.children ? count(n.children) : n.status ? 1 : 0), 0);
const matches = (n: FileExplorerNode, q: string): boolean => !q || n.name.toLowerCase().includes(q) || !!n.children?.some(k => matches(k, q));
const iconFor = (name: string): IconName => /\.tsx$/.test(name) ? 'code' : /\.css$/.test(name) ? 'palette' : /\.png|\.svg$/.test(name) ? 'image' : 'file-text';
/** Project files in a tree you can filter, with the ones the agent touched marked. */
export function FileExplorer({
tree, open, defaultOpen = [], onOpenChange, selected, defaultSelected = '', onSelectedChange, query, defaultQuery = '', onQueryChange,
onlyChanged, defaultOnlyChanged = false, onOnlyChangedChange, summary = n => `${n} files changed by the agent`, fileIcon = iconFor,
placeholder = 'Filter files', className
}: FileExplorerProps) {
const [opened, setOpened] = useControllable(open, defaultOpen, onOpenChange);
const [sel, setSel] = useControllable(selected, defaultSelected, onSelectedChange);
const [text, setText] = useControllable(query, defaultQuery, onQueryChange);
const [only, setOnly] = useControllable(onlyChanged, defaultOnlyChanged, onOnlyChangedChange);
const [foot, setFoot] = useState<'selected' | 'summary'>(only ? 'summary' : 'selected');
const q = text.trim().toLowerCase();
// the tree is drawn afresh when the filter starts or ends and when only changed files show, as on the site;
// it staggers in when it first appears and when that switch flips
const [round, setRound] = useState({ only, q: !!q, n: 0, stagger: true });
if (round.only !== only || round.q !== !!q) setRound({ only, q: !!q, n: round.n + 1, stagger: round.only !== only });
let index = 0;
const branch = (nodes: FileExplorerNode[], path: string, depth: number): ReactNode => nodes
.filter(k => matches(k, q) && (!only || changed(k)))
.map(k => {
const p = path ? path + '/' + k.name : k.name, folder = !!k.children, isOpen = folder && (opened.includes(p) || !!q || only);
const anim = round.stagger ? enter(index++ * 25) : undefined;
const row = (
<button type="button" role="treeitem" aria-expanded={folder ? isOpen : undefined} aria-selected={folder ? undefined : p === sel}
className={cx('dm-fxp-n', isOpen && 'dm-open', p === sel && 'dm-sel', k.status && 'dm-' + k.status, anim?.className)}
style={{ paddingLeft: 6 + depth * 14, ...anim?.style }}
onClick={() => {
if (folder) setOpened(opened.includes(p) ? opened.filter(x => x !== p) : [...opened, p]);
else { setSel(p); setFoot('selected'); }
}}>
<span className="dm-cv">{folder && <Icon name="chev-r" size="xs" />}</span>
<span className="dm-ic"><Icon name={folder ? 'folder' : fileIcon(k.name)} size="xs" /></span>
<span className="dm-nm">{k.name}</span>
{k.status ? <span className={cx('dm-bd', 'dm-' + k.status)}>{k.status}</span> : folder && changed(k) ? <span className="dm-ag" /> : null}
</button>
);
return (
<Fragment key={p}>
{row}
{folder && <div className={cx('dm-fxp-k', !isOpen && 'dm-shut')} role="group" inert={!isOpen}><div>{branch(k.children!, p, depth + 1)}</div></div>}
</Fragment>
);
});
return (
<div data-dm="" className={cx('dm-fxp dm-panel', className)}>
<div className="dm-fxp-h">
<div className="dm-fxp-s">
<Icon name="search" size="xs" />
<input placeholder={placeholder} aria-label="Filter files" spellCheck={false} value={text} onChange={e => setText(e.target.value)} />
</div>
<Tip label="Only changed files">
<button type="button" className={cx('dm-ib dm-fxp-ch', only && 'dm-on')} aria-label="Only changed" aria-pressed={only}
onClick={() => { setOnly(!only); setFoot(only ? 'selected' : 'summary'); }}><Icon name="filter" size="xs" /></button>
</Tip>
</div>
<div className="dm-fxp-tree" role="tree"><Fragment key={round.n}>{branch(tree, '', 0)}</Fragment></div>
<div className="dm-fxp-f dm-muted"><Swap className="dm-fxp-st">{foot === 'summary' ? summary(count(tree)) : sel}</Swap></div>
</div>
);
}
file-explorer.cssReact · 34 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- File explorer ---------- */
.dm-fxp{padding:var(--dm-s-2);display:flex;flex-direction:column;gap:var(--dm-s-1);height:380px}
.dm-fxp-h{display:flex;gap:var(--dm-s-1);align-items:center}
.dm-fxp-s{flex:1;display:flex;align-items:center;gap:var(--dm-s-2);height:32px;padding:0 10px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);color:var(--dm-muted);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-fxp-s{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-fxp-s input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--dm-fg)}
.dm-fxp-s:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-fxp-s input::placeholder{color:var(--dm-muted)}
.dm-fxp-tree{flex:1;overflow:auto;padding:var(--dm-s-1) 0 var(--dm-s-4);-webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 28px),transparent);mask-image:linear-gradient(180deg,#000 calc(100% - 28px),transparent)}
/* a thin scrollbar in the style's hairline grey, not the browser's thick one */
.dm-fxp-tree{scrollbar-width:thin;scrollbar-color:var(--dm-rail) transparent}
.dm-fxp-tree::-webkit-scrollbar{width:8px;height:8px}
.dm-fxp-tree::-webkit-scrollbar-track{background:transparent}
.dm-fxp-tree::-webkit-scrollbar-thumb{background:var(--dm-rail);border:2px solid transparent;background-clip:padding-box;border-radius:var(--dm-r-pill)}
:root:not([data-style="soft"]) .dm-fxp-tree:not(:where([data-style="soft"],[data-style="soft"] *))::-webkit-scrollbar-thumb{border-radius:0}
.dm-fxp-n{display:flex;align-items:center;gap:6px;width:100%;height:30px;padding-right:var(--dm-s-2);border-radius:var(--dm-r-sm);font-size:13px;text-align:left;color:var(--dm-fg2);transition:background .15s,color .15s}
@media (hover:hover) and (pointer:fine){.dm-fxp-n:hover{background:var(--dm-row);color:var(--dm-fg)}}
/* rows fill the scrolling tree, which would cut an outside ring: theirs is drawn inside */
.dm-fxp-n:focus-visible{outline-offset:-2px}
.dm-fxp-n.dm-sel{background:var(--dm-sel);color:var(--dm-fg)}
.dm-fxp-n .dm-cv{width:14px;display:grid;place-items:center;color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.dm-fxp-n.dm-open .dm-cv{transform:rotate(90deg)}
.dm-fxp-n .dm-ic{color:var(--dm-muted);display:grid}
.dm-fxp-n .dm-nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-fxp-n .dm-bd{font-family:var(--dm-mono);font-size:10.5px;font-weight:500;width:14px;text-align:center}
.dm-fxp-n .dm-bd.dm-M{color:var(--dm-warn)}.dm-fxp-n .dm-bd.dm-A{color:var(--dm-ok)}.dm-fxp-n .dm-bd.dm-D{color:var(--dm-bad)}
.dm-fxp-n.dm-D .dm-nm{text-decoration:line-through;color:var(--dm-muted)}
.dm-fxp-n .dm-ag{width:6px;height:6px;border-radius:50%;background:var(--dm-accent)}
.dm-fxp-k{display:grid;grid-template-rows:1fr;transition:grid-template-rows .4s var(--dm-ease)}
.dm-fxp-k.dm-shut{grid-template-rows:0fr}
.dm-fxp-k>div{overflow:hidden}
.dm-fxp-f{font-size:12px;padding:var(--dm-s-1) var(--dm-s-2)}
file-explorer.htmlHTML · 10 lines
<div class="card" data-c="file-explorer">
<div class="stage-in">
<div class="fxp panel" data-reveal="flat">
<div class="fxp-h"><div class="fxp-s"><svg class="i xs"><use href="#i-search"/></svg><input placeholder="Filter files" aria-label="Filter files" spellcheck="false"></div><button class="ib fxp-ch" aria-label="Only changed" data-tip="Only changed files"><svg class="i xs"><use href="#i-filter"/></svg></button></div>
<div class="fxp-tree" role="tree"></div>
<div class="fxp-f muted"><span class="fxp-st"></span></div>
</div>
</div>
</div>
file-explorer.cssHTML · 33 lines
/* ---------- File explorer ---------- */
.fxp{padding:var(--s-2);display:flex;flex-direction:column;gap:var(--s-1);height:380px}
.fxp-h{display:flex;gap:var(--s-1);align-items:center}
.fxp-s{flex:1;display:flex;align-items:center;gap:var(--s-2);height:32px;padding:0 10px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);color:var(--muted);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.fxp-s{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.fxp-s input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--fg)}
.fxp-s:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.fxp-s input::placeholder{color:var(--muted)}
.fxp-tree{flex:1;overflow:auto;padding:var(--s-1) 0 var(--s-4);-webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 28px),transparent);mask-image:linear-gradient(180deg,#000 calc(100% - 28px),transparent)}
/* a thin scrollbar in the style's hairline grey, not the browser's thick one */
.fxp-tree{scrollbar-width:thin;scrollbar-color:var(--rail) transparent}
.fxp-tree::-webkit-scrollbar{width:8px;height:8px}
.fxp-tree::-webkit-scrollbar-track{background:transparent}
.fxp-tree::-webkit-scrollbar-thumb{background:var(--rail);border:2px solid transparent;background-clip:padding-box;border-radius:var(--r-pill)}
:root:not([data-style="soft"]) .fxp-tree:not(:where([data-style="soft"],[data-style="soft"] *))::-webkit-scrollbar-thumb{border-radius:0}
.fxp-n{display:flex;align-items:center;gap:6px;width:100%;height:30px;padding-right:var(--s-2);border-radius:var(--r-sm);font-size:13px;text-align:left;color:var(--fg2);transition:background .15s,color .15s}
@media (hover:hover) and (pointer:fine){.fxp-n:hover{background:var(--row);color:var(--fg)}}
/* rows fill the scrolling tree, which would cut an outside ring: theirs is drawn inside */
.fxp-n:focus-visible{outline-offset:-2px}
.fxp-n.sel{background:var(--sel);color:var(--fg)}
.fxp-n .cv{width:14px;display:grid;place-items:center;color:var(--muted);transition:transform .35s var(--ease)}
.fxp-n.open .cv{transform:rotate(90deg)}
.fxp-n .ic{color:var(--muted);display:grid}
.fxp-n .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fxp-n .bd{font-family:var(--mono);font-size:10.5px;font-weight:500;width:14px;text-align:center}
.fxp-n .bd.M{color:var(--warn)}.fxp-n .bd.A{color:var(--ok)}.fxp-n .bd.D{color:var(--bad)}
.fxp-n.D .nm{text-decoration:line-through;color:var(--muted)}
.fxp-n .ag{width:6px;height:6px;border-radius:50%;background:var(--accent)}
.fxp-k{display:grid;grid-template-rows:1fr;transition:grid-template-rows .4s var(--ease)}
.fxp-k.shut{grid-template-rows:0fr}
.fxp-k>div{overflow:hidden}
.fxp-f{font-size:12px;padding:var(--s-1) var(--s-2)}
file-explorer.jsHTML · 34 lines
DM.def('file-explorer', (r, c) => {
r.innerHTML = r.innerHTML.replace('{search}', I('search','xs')).replace('{filter}', I('filter','xs'));
const T = { n:'', k:[
{ n:'src', k:[
{ n:'components', k:[ { n:'PlanCard.tsx', s:'M' }, { n:'PriceTag.tsx', s:'A' }, { n:'Button.tsx' }, { n:'LegacyCTA.tsx', s:'D' } ] },
{ n:'styles', k:[ { n:'tokens.css', s:'M' }, { n:'globals.css' } ] },
{ n:'pages', k:[ { n:'pricing.mdx', s:'M' }, { n:'index.mdx' } ] }
]},
{ n:'public', k:[ { n:'og-pricing.png', s:'A' }, { n:'favicon.svg' } ] },
{ n:'package.json' }, { n:'README.md' }
]};
const tree = $('.fxp-tree', r), inp = $('.fxp-s input', r), chB = $('.fxp-ch', r), st = $('.fxp-st', r);
const open = new Set(['src','src/components','src/styles']); let only = false, sel = 'src/components/PlanCard.tsx';
const changed = n => n.s || (n.k && n.k.some(changed));
const match = (n, q) => !q || n.n.toLowerCase().includes(q) || (n.k && n.k.some(k => match(k, q)));
const ext = n => /\.(tsx)$/.test(n) ? 'code' : /\.css$/.test(n) ? 'palette' : /\.png|\.svg$/.test(n) ? 'image' : 'file-text';
function build(n, path, d, q){
return n.k.filter(k => match(k, q) && (!only || changed(k))).map(k => {
const p = path ? path + '/' + k.n : k.n, isD = !!k.k, op = isD && (open.has(p) || !!q || only);
return `<button class="fxp-n${isD && op ? ' open' : ''}${p === sel ? ' sel' : ''}${k.s ? ' ' + k.s : ''}" data-p="${p}" ${isD ? 'data-d="1"' : ''} style="padding-left:${6 + d * 14}px" role="treeitem"><span class="cv">${isD ? I('chev-r','xs') : ''}</span><span class="ic">${I(isD ? 'folder' : ext(k.n),'xs')}</span><span class="nm">${k.n}</span>${k.s ? `<span class="bd ${k.s}">${k.s}</span>` : isD && changed(k) ? '<span class="ag"></span>' : ''}</button>` + (isD ? `<div class="fxp-k${op ? '' : ' shut'}"><div>${build(k, p, d + 1, q)}</div></div>` : '');
}).join('');
}
function paint(){
tree.innerHTML = build(T, '', 0, inp.value.trim().toLowerCase());
$$('.fxp-n', tree).forEach(b => b.onclick = () => {
if (b.dataset.d){ const p = b.dataset.p, k = b.nextElementSibling; const o = !open.has(p); o ? open.add(p) : open.delete(p); b.classList.toggle('open', o); k.classList.toggle('shut', !o); }
else { sel = b.dataset.p; $$('.fxp-n', tree).forEach(x => x.classList.toggle('sel', x === b)); swap(st, sel); }
});
}
inp.oninput = paint;
chB.onclick = () => { only = !only; chB.classList.toggle('on', only); swap(st, only ? '6 files changed by the agent' : sel); paint(); stagger($$('.fxp-n', tree), 25); };
st.textContent = sel; paint(); stagger($$('.fxp-n', tree), 25);
});
Questions
What people ask about it.
- How do I mark files the agent changed?
- Set
statuson a file node to 'M', 'A' or 'D'. Parent folders work out on their own that they hold a change and show a dot. - How do I open the tree on a particular file?
- Pass the folder paths in
defaultOpen(oropen), like ['src', 'src/components'], and the file's path indefaultSelected(orselected). - How do I load a file when someone clicks it?
- Use
onSelectedChange. It receives the file's path, built from the folder names joined with slashes, like "src/components/PlanCard.tsx". - Can I change the file icons?
- Yes. Pass
fileIcon, a function that takes the file name and returns an icon name.
More in Workspace
Navigation, search, settings, models and automations around the agent.