Skip to content
Star on GitHub
WorkspaceFile explorer
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

File 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.

$npx designme add file-explorer
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add file-explorer

React

$npx designme add file-explorer --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything FileExplorer takes.

PropTypeWhat it does
treerequiredFileExplorerNode[]
openstring[]Paths of the open folders, like "src/components". Leave it out to let the folders open and close by themselves
defaultOpenstring[]
onOpenChange(paths: string[]) => void
selectedstringPath of the selected file
defaultSelectedstring
onSelectedChange(path: string) => void
querystringThe filter text
defaultQuerystring
onQueryChange(query: string) => void
onlyChangedbooleanShows only the files the agent changed
defaultOnlyChangedboolean
onOnlyChangedChange(only: boolean) => void
summary(changed: number) => stringThe footer while only changed files show. Defaults to "6 files changed by the agent"
fileIcon(name: string) => IconNameThe icon for a file. Defaults to one picked by its extension
placeholderstring
classNamestring
05

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);
});
06

Questions

What people ask about it.

How do I mark files the agent changed?
Set status on 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 (or open), like ['src', 'src/components'], and the file's path in defaultSelected (or selected).
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.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Command paletteJump anywhere or hand a task to the agent from one box.
Memory panelWhat the agent remembers about your team, editable and removable.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Tools and connectionsThe tools the agent can reach, and how much it may do with each.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
All 11 in Workspace →