Cloud|

@k2b/ui

Files

Generic file trees, browser composition, and content-aware previews.

FileBrowserPanel@k2b/uiFileTree@k2b/uiFileView@k2b/ui

A fully interactive, path-first browser over an application-owned in-memory source. Create, upload, rename, move, delete, edit, save, and download without a backend. Tree icons sit in soft chips of their file-type color; folders use the app accent. The preview dialog shows a plain FileView whose Markdown heading becomes the dialog title.

Files

Add

Loading files...

Select a file

Pick a file from the tree to view it.
1 selected
Reports
Meeting notes.md
Team photo.jpg
Archive.zip

TSX

Copy
const source: FileSource = {
list: async () => entries,
read: async (path) => content[path],
write: async (path, value) => update(path, value),
rename: async (from, to) => move(from, to),
remove: async (path) => remove(path),
upload: async (dir, files) => upload(dir, files),
};
<FileBrowserPanel source={source} initialPath="/README.md" />
// A dialog that shows the document's own heading as its title.
<PanelDialog.Header title={title() ?? name} subtitle={name} close={close} />
<PanelDialog.Body>
<FileView variant="plain" file={file} load={load} onDocumentTitle={setTitle} />
</PanelDialog.Body>

FileBrowserPanel, FileTree, and FileView present application-owned file data. They do not depend on Cloud storage, routes, permissions, or mutation APIs.

Selected FileGrid items and FileTree rows take the row selection fill without changing text weight or geometry; see Colors and selection.

Use file components

Use FileBrowserPanel for the ready tree-and-preview composition over a FileSource. Use FileTree for a different layout or selection flow. Use FileView when a page already owns the selected file.

Import

tsx
import {
  FileBrowserPanel,
  FileTree,
  FileTypeIcon,
  fileTypeOf,
  type FileType,
  type FileTypeIconProps,
  FileView,
  canPreviewFile,
  formatFileViewSize,
  getFileViewPreviewKind,
  openFileBrowser,
  type FileBrowserPanelProps,
  type FileSource,
  type FileTreeActions,
  type FileTreeEntry,
  type FileTreeProps,
  type FileViewContent,
  type FileViewFile,
  type FileViewPreviewKind,
  type FileViewProps,
  type FileViewRenderer,
  type FileViewRendererProps,
} from "@k2b/ui";

File and folder icon tiles

FileTypeIcon shows a file or folder glyph in a soft chip of its type color. FileTree rows use it at size="sm"; use size="md" in file-list rows and size="lg" for FileGrid previews without a thumbnail. FileGrid leaves preview content to its host. Folders take the app accent hook --k2b-app-workspace-active.

tsx
<FileTypeIcon name="report.pdf" size="md" />
<FileTypeIcon name="Projects" folder open size="sm" />
Property Type Default Purpose
name string required File or folder name; its extension selects the glyph and color family.
mediaType string none Media type used when resolving the file glyph and family.
folder boolean false Uses the folder family and app accent.
open boolean false Shows the open-folder glyph for a folder.
icon string inferred Overrides the Tabler glyph without changing its type color. Colour utility classes are ignored.
size "sm" | "md" | "lg" "md" Fixed box of 1.25rem, 2rem, or 4.5rem.
class string none Adds classes to the icon wrapper.

data-file-type records one FileType: folder, pdf, image, sheet, document, slides, video, audio, archive, or other. Code, text, and unknown files use the neutral other family. The glyph and chip keep their size and fill on hover, focus, and selection, in light and dark themes. fileTypeOf({ name, mediaType?, folder? }) returns this family without rendering.

The component renders on the server and needs no browser code. Its wrapper is decorative (aria-hidden="true"); keep a visible file name beside it.

FileSource

FileSource is the asynchronous boundary:

ts
type FileSource = {
  list(): Promise<FileTreeEntry[]>;
  read(path: string): Promise<FileViewContent>;
  write?(path: string, content: string, encoding?: "utf8" | "base64"):
    Promise<void>;
  remove?(path: string): Promise<void>;
  rename?(from: string, to: string): Promise<void>;
  upload?(dirPath: string, files: File[]): Promise<void>;
  downloadHref?(path: string): string | null;
  isReadOnly?(path: string): boolean;
};

Only supplied capabilities receive matching controls. readOnly on FileBrowserPanel hides every mutation even when the source implements it. isReadOnly can protect individual paths such as generated inputs. Capabilities are independent: for example, a source may offer rename or remove without offering write.

The host authenticates every operation and checks authorization again inside the source. Hiding a control is not an authorization boundary.

Path-first tree

Each FileTreeEntry has an absolute-style path such as /src/app.tsx. Folders are derived from file paths; explicit { path, kind: "folder" } entries represent empty folders. Optional displayName changes the visible and accessible label; selection, reading, and renaming still use the original path.

selectedPath and onSelect own selection. expandedPaths and onExpandedChange provide controlled expansion; otherwise folders start expanded. FileTreeActions enables rename, remove, create, move, and download affordances individually.

contextMenu adds application-specific menu items without replacing the built-in actions.

File previews

FileView receives a FileViewFile, an asynchronous load function, and optional preview and download URLs. Text, Markdown, JSON, delimited text, images, PDF, audio, and video use built-in renderers. Supplying save enables editing for compatible text renderers.

previewHref is an authenticated URL that answers inline. With it, images, PDF, audio, and video render natively from that URL and load is not called. Video plays in VideoPlayer: the browser fetches it by range from previewHref, so serve that URL with 206 answers to Range requests. A video the browser cannot decode shows the player's fallback with a download link to downloadHref. downloadHref only adds download actions; it is never a preview source, because browsers do not show an attachment response inline. Without previewHref, a PDF renders from the bytes load returns, through PdfPreview: its "Open preview" action, which opens it in the browser's own viewer, sits above the document in the preview frame. There is no visible heading, because the host already names the file; the document frame keeps the file name as its accessible title.

Pass revision to refetch a FileView whose path did not change. A FileBrowserPanel forwards its refreshKey to both the file list and the selected preview. A PDF preview reloads only when the refetched bytes differ, so an unchanged file keeps its open page.

Markdown files edit through MarkdownEditor. Other UTF-8 source and text files use a plain monospace textarea inside the same editor chrome, so Markdown formatting controls and completions are not offered for code.

FileViewContent is either UTF-8 or base64:

ts
type FileViewContent = {
  encoding: "utf8" | "base64";
  content: string;
  mediaType: string;
};

getFileViewPreviewKind returns the inferred FileViewPreviewKind. Both helpers apply the built-in size limits when size is supplied: 2 MiB for text, Markdown, JSON and delimited text; 25 MiB for images; 50 MiB for PDF, audio, and video. Unsupported or oversized files return null from getFileViewPreviewKind and false from canPreviewFile. Video means MP4, M4V, QuickTime (.mov), WebM, and Ogg video, by media type or extension. Whether a browser decodes one depends on its codec. A host that streams video by range can omit size to preview it at any size. formatFileViewSize produces the compact size label used by the preview.

Pass renderers to FileView or FileBrowserPanel to add application-specific renderers before the built-ins. Extensions are scoped to that component instance, so concurrent SSR requests and independently mounted applications cannot mutate each other's renderer set.

FileView reports local edits through onDirtyChange. FileBrowserPanel uses that signal to guard selection changes; pass confirmDiscard to own the confirmation copy and policy. Without it, the shared confirmation prompt is used.

Dialog helper

openFileBrowser({ source, title, subtitle, icon }) opens the shared panel in a dialog and resolves when it closes. The supplied source has the same capability and authorization responsibilities as an inline panel.

API reference

ts
type FileTreeEntry = {
  path: string; displayName?: string; kind?: "file" | "folder"; size?: number; mediaType?: string;
  updatedAt?: string; icon?: string; badge?: string;
};

type FileTreeActions = {
  rename?: (path: string, nextName: string) => void | Promise<void>;
  remove?: (path: string) => void | Promise<void>; createFile?: (dirPath: string) => void | Promise<void>;
  createFolder?: (dirPath: string) => void | Promise<void>;
  move?: (path: string, targetDir: string) => void | Promise<void>;
  download?: (path: string, isFolder: boolean) => void | Promise<void>;
};

type FileTreeProps = {
  entries: FileTreeEntry[]; selectedPath?: string | null; onSelect?: (entry: FileTreeEntry) => void;
  expandedPaths?: Set<string>; onExpandedChange?: (expanded: Set<string>) => void;
  contextMenu?: (entry: FileTreeEntry) => DropdownItem[]; actions?: FileTreeActions; label?: string;
  class?: string;
};

type FileViewFile = {
  path: string; mediaType?: string; size?: number;
};

type FileViewPreviewKind = "markdown" | "image" | "pdf" | "json" | "delimited-text" | "audio" | "video" | "text";

type FileViewRendererProps = {
  previewLines?: number; onExpandPreview?: () => void; headingScale?: "compact" | "normal" | "large";
  file: FileViewFile; content: FileViewContent; previewHref: string | null; downloadHref: string | null;
  editor: {
    draft: () => string;
    setDraft: (value: string) => void;
    dirty: () => boolean;
    saving: () => boolean;
    save: () => Promise<void>;
  } | null;
};

type FileViewRenderer = {
  id: string; match: (file: FileViewFile, content: FileViewContent) => boolean;
  component: Component<FileViewRendererProps>; editable?: boolean;
};

type FileViewProps = {
  variant?: "default" | "plain"; onDocumentTitle?: (title: string | null) => void;
  previewLines?: number; onExpandPreview?: () => void; headingScale?: "compact" | "normal" | "large";
  file: FileViewFile; load: () => Promise<FileViewContent>; revision?: unknown;
  registerRefresh?: (refresh: () => Promise<void>) => void | (() => void);
  save?: (content: string) => Promise<void>; previewHref?: string | null; downloadHref?: string | null;
  renderers?: readonly FileViewRenderer[]; onDirtyChange?: (dirty: boolean) => void; class?: string;
};

type FileBrowserPanelProps = {
  source: FileSource; readOnly?: boolean; refreshKey?: unknown; initialPath?: string;
  onSelectedPathChange?: (path: string | null) => void; renderers?: readonly FileViewRenderer[];
  confirmDiscard?: (path: string, nextPath: string | null) => boolean | Promise<boolean>; class?: string;
  registerRefresh?: (refresh: () => Promise<void>) => void | (() => void);
};

FileSource and FileViewContent are defined above. Component<P> is a Solid component taking P and returning JSX. Renderer order matters: the first matching custom renderer wins before built-ins. Its editable capability and the supplied save determine editing availability. registerRefresh receives a refresh function and may return cleanup; the host should release stored references on cleanup. onSelectedPathChange reports selection; initialPath initializes it rather than controlling it. refreshKey/revision may be any changing identity.

contextMenu returns DropdownItem entries. FileTreeActions.rename receives a path and new name; FileSource.rename receives full from/to paths. Size fields are bytes. getFileViewPreviewKind(file: FileViewFile): FileViewPreviewKind | null infers the format; canPreviewFile(file: FileViewFile): boolean applies format and size support; formatFileViewSize(bytes: number): string formats bytes.

Accessibility

FileTree exposes tree and tree-item roles with arrow-key navigation. Selection, expansion, context actions, preview actions, and text editing remain keyboard reachable.

Runtime

The static tree and preview shells render on the server. Resource loading, selection, editing, drag and drop, media controls, prompts, and dialogs require browser code.

Example

tsx
const entries = (): FileTreeEntry[] =>
  Object.entries(contentByPath).map(([path, content]) => ({
    path,
    mediaType: path.endsWith(".md") ? "text/markdown" : "text/plain",
    size: new TextEncoder().encode(content).byteLength,
  }));

const source: FileSource = {
  list: async () => entries(),
  read: async (path) => ({
    encoding: "utf8",
    content: contentByPath[path] ?? "",
    mediaType: path.endsWith(".md") ? "text/markdown" : "text/plain",
  }),
  write: async (path, content) => {
    contentByPath[path] = content;
  },
  rename: async (from, to) => {
    contentByPath[to] = contentByPath[from] ?? "";
    delete contentByPath[from];
  },
  remove: async (path) => {
    delete contentByPath[path];
  },
};

<FileBrowserPanel
  source={source}
  initialPath="/README.md"
  renderers={applicationRenderers}
  confirmDiscard={(path) => confirm(`Discard changes to ${path}?`)}
/>;

Direct composition

tsx
<FileTree
  entries={entries()}
  selectedPath={selectedPath()}
  onSelect={(entry) => setSelectedPath(entry.path)}
/>

<FileView
  file={{ path: selectedPath(), mediaType: "text/plain" }}
  load={() => source.read(selectedPath())}
  save={(content) => source.write!(selectedPath(), content)}
  renderers={applicationRenderers}
/>

Paginated list and grid views

Use FileGrid with createCollectionSelection when the host owns a paginated collection rather than an entire tree. Pass the same controller to DataTable.selection and FileGrid.selection to preserve selection when changing views. Both require stable getRowId values. The controller's ids accessor is the selectable collection in display order; removed IDs are pruned.

tsx
const selection = createCollectionSelection({ ids: () => entries().map(file => file.path) });
<FileGrid
  rows={entries()}
  getRowId={file => file.path}
  selection={selection}
  label="Files"
  size="md"
  renderPreview={file => <FileTypeIcon name={file.name} folder={file.directory} size="lg" />}
  renderLabel={file => file.name}
  renderMeta={file => file.sizeLabel}
  onOpen={openFile}
/>

Click selects, Ctrl/Cmd-click toggles, and Shift-click selects a range. Arrow keys, Home, and End navigate; Space toggles, Ctrl/Cmd-A selects the supplied collection, and Escape clears it. Enter and double-click call onOpen (or DataTable.onRowDoubleClick). Nested controls retain their own behavior. renderActions accepts host-owned buttons or checkboxes; itemProps adds host attributes such as drag-and-drop handlers to each tile. The host owns data, context menus, authorization, previews, and navigation; onContextMenu can align selection before opening a menu. Use sm, md, or lg for tile size. FileGrid has no scroll owner; place it in ScrollArea. A DataTable already owns scrolling.

layout="list" shows one compact row per item instead of a tile: a 2 rem preview slot, the name on one line, and the meta below it, with actions at the trailing edge. Rows have no lines between them; hover and selection use the same fills as tiles. Use it for choosing among files that have no thumbnail, for example in a picker dialog. Keep DataTable for lists that need columns, sorting, or row structure.

A click that replaces the rows, for example by opening a folder, does not turn a double-click into a second action: the rest of the gesture is ignored by the item that moved under the pointer.

Three options of createCollectionSelection serve pickers:

  • isDisabled(id) marks an item that cannot be selected or opened, for example a file a picker cannot accept. It stays in ids and in focus order, so arrow keys and screen readers still reach it. FileGrid gives it aria-disabled and muted text, and clicks, Space, and Enter do nothing; ranges and select-all leave it out. Say why in renderMeta.
  • multiple: false keeps at most one item selected. A click or arrow key selects only that item; ranges and Ctrl/Cmd-A do nothing. The grid reports aria-multiselectable="false".
  • checklist: true makes a plain click or tap toggle one item, so touch users can choose several. Arrow keys then only move focus, Space toggles, and Shift-click still selects a range.

Escape clears a selection. With nothing selected, it is left to the host, so Escape inside a dialog closes the dialog.

For signed native media URLs, FileView.crossOrigin="anonymous" omits credentials on cross-origin media requests. onPreviewError reports native image and audio load errors, and a video that falls back, so the host can request a fresh URL and offer retry. These options leave existing media behavior unchanged when omitted. PDF and text content can instead be fetched by the host with an abort signal and explicit byte limit.

Compact previews

Pass previewLines={5} and onExpandPreview to FileView for a detail-panel excerpt. Text and Markdown show five source lines; CSV/TSV show five data rows plus the header. The remaining-line button calls onExpandPreview. Omit previewLines in the expanded view. CSV/TSV then paginate through all loaded records, 200 per page, retaining the header. The 50-column limit is displayed when columns are omitted; existing file-size limits still apply.

headingScale="compact" uses smaller Markdown headings and tighter paragraph spacing in a small surface. Use headingScale="normal" for a full-document view. The default remains compact. These options also reach custom renderers through FileViewRendererProps.

Use variant="plain" when the host already frames the preview, such as a dialog, a detail panel group, or a page section. The default keeps the preview frame for a preview that stands alone.

A plain preview has no frame, background, or document padding. It keeps its natural height, so the host is the one element that scrolls; a host that used to rely on a plain preview scrolling inside a bounded box now scrolls it itself. Wide content, such as a CSV table, scrolls sideways in place. Markdown keeps a reading measure of 72 characters in wider hosts; its tables and code blocks scroll within that column. Around a document, the preview reaches 0.5rem into its host on both sides and pads the text back into place, so a Markdown table's lines can run past the text. A host that clips leaves that much room around the preview. Text and source files show their line numbers and soft-wrapped lines directly on the host surface, without a code box or its copy header, so the host offers copying where it needs it. JSON shows its keys and values on the host surface without their own card. An excerpt's "more lines" action lines up with the content.

A host that bounds its height can stretch a plain preview to fill it, for example with flex: 1 1 auto; min-height: 0. Images and video then fit that height, and a table scrolls inside the preview, so its header row and the preview actions stay in view. Without a bounded height, a video keeps a 16:9 frame.

Document title

Pass onDocumentTitle when the host shows its own title, for example a preview dialog. When the Markdown preview starts with a level-one heading, FileView reports the heading's plain text and leaves that heading out of the preview, so the title is not shown twice. It reports null for Markdown without a leading heading, for every other file type, and when the file fails to load; the host then titles the preview with the file name. Nothing is reported while the file loads. A leading YAML front matter block is skipped first. The heading stays in the Markdown editor, and the reported title follows edits.

The heading stays in the document, and null is reported, when it contains a link or an image, which a plain-text title would lose, and when nothing follows it, so the preview is never empty. The title reaches the host from an effect, so only in the browser; a server render shows the document without its leading heading before mount.

Pass the file name as a constant subtitle, as below, so the header does not gain a line when the title arrives. A title that may wrap onto more lines than the file name needs its own plan, for example showing the lines below it only together with the title.

tsx
const [title, setTitle] = createSignal<string | null | undefined>(undefined);

<PanelDialog.Header title={title() ?? fileName} subtitle={fileName} close={close} />
<PanelDialog.Body>
  <FileView variant="plain" headingScale="normal" file={file} load={load} onDocumentTitle={setTitle} />
</PanelDialog.Body>

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.