UI Components Medium
File Tree
Collapsible file explorer tree with folder/file icons, expand/collapse all, active file highlight, and right-click context menu.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1e1e2e;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 16px;
}
.demo {
width: 100%;
max-width: 320px;
position: relative;
}
.ft-panel {
background: #181825;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
overflow: hidden;
}
.ft-toolbar {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ft-panel-title {
font-size: 11px;
font-weight: 700;
color: #6c7086;
text-transform: uppercase;
letter-spacing: 0.07em;
flex: 1;
}
.ft-toolbar-actions {
display: flex;
gap: 4px;
}
.ft-icon-btn {
background: none;
border: none;
color: #6c7086;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
line-height: 1;
}
.ft-icon-btn:hover {
color: #cdd6f4;
background: rgba(255, 255, 255, 0.05);
}
.ft-tree {
padding: 6px 0;
}
/* Tree items */
.ft-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px 3px 0;
cursor: pointer;
border-radius: 4px;
margin: 1px 4px;
user-select: none;
color: #cdd6f4;
font-size: 13px;
}
.ft-item:hover {
background: rgba(205, 214, 244, 0.08);
}
.ft-item.active {
background: rgba(137, 180, 250, 0.15);
color: #89b4fa;
}
.ft-indent {
display: inline-block;
width: 20px;
flex-shrink: 0;
}
.ft-toggle {
width: 14px;
font-size: 10px;
color: #6c7086;
text-align: center;
flex-shrink: 0;
transition: transform 0.15s;
}
.ft-toggle.open {
transform: rotate(90deg);
}
.ft-toggle-space {
width: 14px;
flex-shrink: 0;
}
.ft-icon {
font-size: 14px;
flex-shrink: 0;
}
.ft-name {
flex: 1;
}
.ft-children.collapsed {
display: none;
}
/* File extension colors */
.ext-ts {
color: #7dcfff;
}
.ext-tsx {
color: #7dcfff;
}
.ext-js {
color: #e0af68;
}
.ext-css {
color: #bb9af7;
}
.ext-md {
color: #9ece6a;
}
.ext-json {
color: #e0af68;
}
.ext-svg {
color: #ff9e64;
}
/* Context menu */
.ft-ctx-menu {
position: fixed;
background: #1e1e2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
min-width: 160px;
animation: ctx-in 0.12s ease;
}
@keyframes ctx-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.ctx-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: none;
border: none;
color: #cdd6f4;
font-size: 13px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
text-align: left;
}
.ctx-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.ctx-item--danger {
color: #f38ba8;
}
.ctx-item--danger:hover {
background: rgba(243, 139, 168, 0.12);
}
.ctx-divider {
height: 1px;
background: rgba(255, 255, 255, 0.08);
margin: 3px 4px;
}const TREE = {
name: "my-app",
type: "folder",
open: true,
children: [
{
name: "src",
type: "folder",
open: true,
children: [
{
name: "components",
type: "folder",
open: true,
children: [
{ name: "Button.tsx", type: "file" },
{ name: "Card.tsx", type: "file" },
{ name: "Modal.tsx", type: "file" },
],
},
{
name: "pages",
type: "folder",
open: false,
children: [
{ name: "index.tsx", type: "file" },
{ name: "about.tsx", type: "file" },
{
name: "api",
type: "folder",
open: false,
children: [
{ name: "auth.ts", type: "file" },
{ name: "users.ts", type: "file" },
],
},
],
},
{
name: "styles",
type: "folder",
open: false,
children: [
{ name: "globals.css", type: "file" },
{ name: "components.css", type: "file" },
],
},
{
name: "lib",
type: "folder",
open: false,
children: [
{ name: "utils.ts", type: "file" },
{ name: "api.ts", type: "file" },
],
},
],
},
{
name: "public",
type: "folder",
open: false,
children: [
{ name: "logo.svg", type: "file" },
{ name: "favicon.ico", type: "file" },
],
},
{ name: "package.json", type: "file" },
{ name: "tsconfig.json", type: "file" },
{ name: "README.md", type: "file" },
],
};
const ICONS = {
folder: "📁",
folderOpen: "📂",
tsx: "⚛",
ts: "🔷",
js: "🟨",
css: "🎨",
md: "📝",
json: "📋",
svg: "🖼",
ico: "🖼",
default: "📄",
};
function getExt(name) {
return name.split(".").pop().toLowerCase();
}
function getIcon(node) {
if (node.type === "folder") return node.open ? ICONS.folderOpen : ICONS.folder;
const ext = getExt(node.name);
return ICONS[ext] || ICONS.default;
}
let activeNode = null;
function buildNode(node, depth = 0) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "ft-item";
const indent = document.createElement("span");
indent.className = "ft-indent";
indent.style.width = depth * 16 + "px";
let toggleEl;
if (node.type === "folder" && node.children?.length) {
toggleEl = document.createElement("span");
toggleEl.className = "ft-toggle" + (node.open ? " open" : "");
toggleEl.textContent = "▶";
} else {
toggleEl = document.createElement("span");
toggleEl.className = "ft-toggle-space";
}
const icon = document.createElement("span");
icon.className = "ft-icon";
icon.textContent = getIcon(node);
const name = document.createElement("span");
name.className = "ft-name";
if (node.type === "file") {
const ext = getExt(node.name);
name.classList.add(`ext-${ext}`);
}
name.textContent = node.name;
row.appendChild(indent);
row.appendChild(toggleEl);
row.appendChild(icon);
row.appendChild(name);
wrap.appendChild(row);
let childrenEl = null;
if (node.type === "folder" && node.children) {
childrenEl = document.createElement("div");
childrenEl.className = "ft-children" + (node.open ? "" : " collapsed");
node.children.forEach((child) => childrenEl.appendChild(buildNode(child, depth + 1)));
wrap.appendChild(childrenEl);
}
row.addEventListener("click", () => {
if (activeNode) activeNode.classList.remove("active");
row.classList.add("active");
activeNode = row;
if (node.type === "folder" && childrenEl) {
node.open = !node.open;
toggleEl.classList.toggle("open", node.open);
childrenEl.classList.toggle("collapsed", !node.open);
icon.textContent = getIcon(node);
}
});
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
showCtx(e.clientX, e.clientY);
});
return wrap;
}
function showCtx(x, y) {
const menu = document.getElementById("ctxMenu");
menu.hidden = false;
menu.style.left = x + "px";
menu.style.top = y + "px";
}
document.addEventListener("click", () => {
document.getElementById("ctxMenu").hidden = true;
});
function setAllOpen(node, open) {
if (node.type === "folder") {
node.open = open;
if (node.children) node.children.forEach((c) => setAllOpen(c, open));
}
}
document.getElementById("expandAllBtn").addEventListener("click", () => {
setAllOpen(TREE, true);
document.getElementById("ftTree").innerHTML = "";
document.getElementById("ftTree").appendChild(buildNode(TREE));
});
document.getElementById("collapseAllBtn").addEventListener("click", () => {
setAllOpen(TREE, false);
TREE.open = true;
document.getElementById("ftTree").innerHTML = "";
document.getElementById("ftTree").appendChild(buildNode(TREE));
});
document.getElementById("ftTree").appendChild(buildNode(TREE));<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File Tree</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div class="ft-panel">
<div class="ft-toolbar">
<span class="ft-panel-title">Explorer</span>
<div class="ft-toolbar-actions">
<button class="ft-icon-btn" id="expandAllBtn" title="Expand all">⊕</button>
<button class="ft-icon-btn" id="collapseAllBtn" title="Collapse all">⊖</button>
</div>
</div>
<div class="ft-tree" id="ftTree"></div>
</div>
<!-- Context menu -->
<div class="ft-ctx-menu" id="ctxMenu" hidden>
<button class="ctx-item">📄 New File</button>
<button class="ctx-item">📁 New Folder</button>
<div class="ctx-divider"></div>
<button class="ctx-item">✏️ Rename</button>
<button class="ctx-item ctx-item--danger">🗑 Delete</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useCallback, useEffect } from "react";
type FileType =
| "folder"
| "ts"
| "tsx"
| "js"
| "jsx"
| "json"
| "css"
| "md"
| "env"
| "git"
| "lock";
interface TreeNode {
id: string;
name: string;
type: FileType;
children?: TreeNode[];
modified?: boolean;
untracked?: boolean;
}
const TREE: TreeNode[] = [
{
id: "src",
name: "src",
type: "folder",
children: [
{
id: "components",
name: "components",
type: "folder",
children: [
{ id: "button", name: "Button.tsx", type: "tsx" },
{ id: "input", name: "Input.tsx", type: "tsx", modified: true },
{ id: "modal", name: "Modal.tsx", type: "tsx" },
{ id: "nav", name: "Navbar.tsx", type: "tsx" },
],
},
{
id: "hooks",
name: "hooks",
type: "folder",
children: [
{ id: "useauth", name: "useAuth.ts", type: "ts" },
{ id: "usefetch", name: "useFetch.ts", type: "ts", untracked: true },
],
},
{
id: "lib",
name: "lib",
type: "folder",
children: [
{ id: "utils", name: "utils.ts", type: "ts" },
{ id: "api", name: "api.ts", type: "ts", modified: true },
],
},
{ id: "apptsx", name: "App.tsx", type: "tsx" },
{ id: "maintsx", name: "main.tsx", type: "tsx" },
],
},
{
id: "public",
name: "public",
type: "folder",
children: [
{ id: "indexhtml", name: "index.html", type: "md" },
{ id: "favicon", name: "favicon.svg", type: "env" },
],
},
{ id: "pkgjson", name: "package.json", type: "json" },
{ id: "tsconfigjson", name: "tsconfig.json", type: "json" },
{ id: "tailwindcfg", name: "tailwind.config.js", type: "js" },
{ id: "envlocal", name: ".env.local", type: "env", untracked: true },
{ id: "gitignore", name: ".gitignore", type: "git" },
{ id: "readme", name: "README.md", type: "md", modified: true },
];
const TYPE_ICON: Record<FileType, { color: string; label: string }> = {
folder: { color: "#e3b341", label: "📁" },
ts: { color: "#3178c6", label: "TS" },
tsx: { color: "#61dafb", label: "TSX" },
js: { color: "#f7df1e", label: "JS" },
jsx: { color: "#61dafb", label: "JSX" },
json: { color: "#ffca28", label: "{}" },
css: { color: "#264de4", label: "CS" },
md: { color: "#ffffff", label: "MD" },
env: { color: "#7ee787", label: "EN" },
git: { color: "#f05032", label: "GI" },
lock: { color: "#8b949e", label: "LK" },
};
function FileIcon({ type }: { type: FileType }) {
const { color, label } = TYPE_ICON[type];
if (type === "folder") {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill={color} stroke="none" opacity="0.9">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z" />
</svg>
);
}
return (
<span
className="text-[8px] font-bold rounded px-0.5 leading-none flex items-center"
style={{
color,
background: color + "22",
border: `1px solid ${color}44`,
minWidth: 16,
height: 14,
justifyContent: "center",
}}
>
{label}
</span>
);
}
function AnimatedChildren({ open, children }: { open: boolean; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | "auto">(0);
useEffect(() => {
if (!ref.current) return;
if (open) {
const h = ref.current.scrollHeight;
setHeight(h);
const timer = setTimeout(() => setHeight("auto"), 200);
return () => clearTimeout(timer);
} else {
if (height === "auto") {
setHeight(ref.current.scrollHeight);
requestAnimationFrame(() => setHeight(0));
} else {
setHeight(0);
}
}
}, [open]);
return (
<div
ref={ref}
style={{
height: typeof height === "number" ? height : undefined,
overflow: height === "auto" ? "visible" : "hidden",
transition: "height 0.18s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
{children}
</div>
);
}
function TreeItem({
node,
depth,
activeId,
onActivate,
expandedIds,
onToggle,
}: {
node: TreeNode;
depth: number;
activeId: string | null;
onActivate: (id: string) => void;
expandedIds: Set<string>;
onToggle: (id: string) => void;
}) {
const isFolder = node.type === "folder";
const isOpen = expandedIds.has(node.id);
const isActive = activeId === node.id;
const handleClick = () => {
if (isFolder) onToggle(node.id);
onActivate(node.id);
};
return (
<div>
<div
onClick={handleClick}
className={`flex items-center gap-1.5 py-[3px] px-2 rounded-md cursor-pointer select-none transition-colors group ${
isActive
? "bg-[#21262d] text-[#e6edf3]"
: "text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.03]"
}`}
style={{ paddingLeft: depth * 16 + 8 }}
>
{/* Chevron (folders) */}
{isFolder ? (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className={`flex-shrink-0 transition-transform duration-150 ${isOpen ? "rotate-90" : ""} text-[#484f58]`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
) : (
<span className="w-2.5 flex-shrink-0" />
)}
<FileIcon type={node.type} />
<span className={`text-[12px] flex-1 truncate ${node.modified ? "text-[#e3b341]" : ""}`}>
{node.name}
</span>
{/* Git indicators */}
{node.modified && <span className="text-[9px] font-bold text-[#e3b341] ml-auto">M</span>}
{node.untracked && <span className="text-[9px] font-bold text-green-400 ml-auto">U</span>}
</div>
{isFolder && node.children && (
<AnimatedChildren open={isOpen}>
{node.children.map((child) => (
<TreeItem
key={child.id}
node={child}
depth={depth + 1}
activeId={activeId}
onActivate={onActivate}
expandedIds={expandedIds}
onToggle={onToggle}
/>
))}
</AnimatedChildren>
)}
</div>
);
}
function getInitialExpanded(nodes: TreeNode[], max = 1, depth = 0): string[] {
if (depth >= max) return [];
const ids: string[] = [];
for (const n of nodes) {
if (n.type === "folder") {
ids.push(n.id);
if (n.children) ids.push(...getInitialExpanded(n.children, max, depth + 1));
}
}
return ids;
}
export default function FileTreeRC() {
const [activeId, setActiveId] = useState<string | null>("apptsx");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(getInitialExpanded(TREE)));
const toggle = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const expandAll = () => {
const all: string[] = [];
function collect(nodes: TreeNode[]) {
for (const n of nodes) {
if (n.type === "folder") {
all.push(n.id);
if (n.children) collect(n.children);
}
}
}
collect(TREE);
setExpandedIds(new Set(all));
};
const collapseAll = () => setExpandedIds(new Set());
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[360px]">
<div className="bg-[#161b22] border border-[#30363d] rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-[#21262d] border-b border-[#30363d]">
<span className="text-[11px] font-bold text-[#8b949e] uppercase tracking-wider">
Explorer
</span>
<div className="flex gap-1">
<button
onClick={expandAll}
title="Expand all"
className="p-1 rounded text-[#484f58] hover:text-[#e6edf3] transition-colors"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
onClick={collapseAll}
title="Collapse all"
className="p-1 rounded text-[#484f58] hover:text-[#e6edf3] transition-colors"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</svg>
</button>
</div>
</div>
{/* Tree */}
<div className="py-1.5 px-1">
{TREE.map((node) => (
<TreeItem
key={node.id}
node={node}
depth={0}
activeId={activeId}
onActivate={setActiveId}
expandedIds={expandedIds}
onToggle={toggle}
/>
))}
</div>
{/* Footer */}
{activeId && (
<div className="px-3 py-2 border-t border-[#30363d] bg-[#0d1117]">
<p className="text-[10px] text-[#484f58] font-mono truncate">
/{activeId.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}
</p>
</div>
)}
</div>
{/* Legend */}
<div className="flex gap-3 mt-2 text-[10px] text-[#484f58]">
<span className="flex items-center gap-1">
<span className="text-[#e3b341] font-bold">M</span> Modified
</span>
<span className="flex items-center gap-1">
<span className="text-green-400 font-bold">U</span> Untracked
</span>
</div>
</div>
</div>
);
}<script setup>
import { ref, computed } from "vue";
const TREE = [
{
id: "src",
name: "src",
type: "folder",
children: [
{
id: "components",
name: "components",
type: "folder",
children: [
{ id: "button", name: "Button.tsx", type: "tsx" },
{ id: "input", name: "Input.tsx", type: "tsx", modified: true },
{ id: "modal", name: "Modal.tsx", type: "tsx" },
{ id: "nav", name: "Navbar.tsx", type: "tsx" },
],
},
{
id: "hooks",
name: "hooks",
type: "folder",
children: [
{ id: "useauth", name: "useAuth.ts", type: "ts" },
{ id: "usefetch", name: "useFetch.ts", type: "ts", untracked: true },
],
},
{
id: "lib",
name: "lib",
type: "folder",
children: [
{ id: "utils", name: "utils.ts", type: "ts" },
{ id: "api", name: "api.ts", type: "ts", modified: true },
],
},
{ id: "apptsx", name: "App.tsx", type: "tsx" },
{ id: "maintsx", name: "main.tsx", type: "tsx" },
],
},
{
id: "public",
name: "public",
type: "folder",
children: [
{ id: "indexhtml", name: "index.html", type: "md" },
{ id: "favicon", name: "favicon.svg", type: "env" },
],
},
{ id: "pkgjson", name: "package.json", type: "json" },
{ id: "tsconfigjson", name: "tsconfig.json", type: "json" },
{ id: "tailwindcfg", name: "tailwind.config.js", type: "js" },
{ id: "envlocal", name: ".env.local", type: "env", untracked: true },
{ id: "gitignore", name: ".gitignore", type: "git" },
{ id: "readme", name: "README.md", type: "md", modified: true },
];
const TYPE_ICON = {
folder: { color: "#e3b341", label: "" },
ts: { color: "#3178c6", label: "TS" },
tsx: { color: "#61dafb", label: "TSX" },
js: { color: "#f7df1e", label: "JS" },
jsx: { color: "#61dafb", label: "JSX" },
json: { color: "#ffca28", label: "{}" },
css: { color: "#264de4", label: "CS" },
md: { color: "#ffffff", label: "MD" },
env: { color: "#7ee787", label: "EN" },
git: { color: "#f05032", label: "GI" },
lock: { color: "#8b949e", label: "LK" },
};
function getInitialExpanded(nodes, max = 1, depth = 0) {
if (depth >= max) return [];
const ids = [];
for (const n of nodes) {
if (n.type === "folder") {
ids.push(n.id);
if (n.children) ids.push(...getInitialExpanded(n.children, max, depth + 1));
}
}
return ids;
}
const activeId = ref("apptsx");
const expandedIds = ref(new Set(getInitialExpanded(TREE)));
function toggle(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function handleClick(node) {
if (node.type === "folder") toggle(node.id);
activeId.value = node.id;
}
function expandAll() {
const all = [];
function collect(nodes) {
for (const n of nodes) {
if (n.type === "folder") {
all.push(n.id);
if (n.children) collect(n.children);
}
}
}
collect(TREE);
expandedIds.value = new Set(all);
}
function collapseAll() {
expandedIds.value = new Set();
}
// Flatten tree into a visible list for rendering without recursion
const flatList = computed(() => {
const rows = [];
function walk(nodes, depth) {
for (const node of nodes) {
const isFolder = node.type === "folder";
const isOpen = isFolder && expandedIds.value.has(node.id);
rows.push({ node, depth, isFolder, isOpen });
if (isFolder && isOpen && node.children) {
walk(node.children, depth + 1);
}
}
}
walk(TREE, 0);
return rows;
});
const activePath = computed(() =>
activeId.value ? "/" + activeId.value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) : ""
);
</script>
<template>
<div class="file-tree-wrapper">
<div class="file-tree-panel">
<div class="file-tree-card">
<div class="file-tree-header">
<span class="header-label">Explorer</span>
<div class="header-actions">
<button @click="expandAll" title="Expand all" class="action-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</button>
<button @click="collapseAll" title="Collapse all" class="action-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
</button>
</div>
</div>
<div class="file-tree-body">
<div
v-for="row in flatList"
:key="row.node.id"
class="tree-row"
:class="{ active: activeId === row.node.id }"
:style="{ paddingLeft: row.depth * 16 + 8 + 'px' }"
@click="handleClick(row.node)"
>
<!-- Chevron -->
<svg v-if="row.isFolder"
width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5"
class="chevron" :class="{ open: row.isOpen }"
>
<polyline points="9 18 15 12 9 6"/>
</svg>
<span v-else class="chevron-spacer"></span>
<!-- Icon -->
<svg v-if="row.isFolder" width="14" height="14" viewBox="0 0 24 24" fill="#e3b341" stroke="none" opacity="0.9">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/>
</svg>
<span v-else class="file-badge" :style="{
color: TYPE_ICON[row.node.type].color,
background: TYPE_ICON[row.node.type].color + '22',
border: '1px solid ' + TYPE_ICON[row.node.type].color + '44'
}">{{ TYPE_ICON[row.node.type].label }}</span>
<!-- Name -->
<span class="node-name" :class="{ modified: row.node.modified }">{{ row.node.name }}</span>
<!-- Git indicators -->
<span v-if="row.node.modified" class="git-mod">M</span>
<span v-if="row.node.untracked" class="git-untracked">U</span>
</div>
</div>
<div v-if="activeId" class="file-tree-footer">
<p class="footer-path">{{ activePath }}</p>
</div>
</div>
<div class="legend">
<span class="legend-item"><span class="git-mod-inline">M</span> Modified</span>
<span class="legend-item"><span class="git-untracked-inline">U</span> Untracked</span>
</div>
</div>
</div>
</template>
<style scoped>
.file-tree-wrapper {
min-height: 100vh;
background: #0d1117;
padding: 1.5rem;
display: flex;
justify-content: center;
}
.file-tree-panel { width: 100%; max-width: 360px; }
.file-tree-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
overflow: hidden;
}
.file-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: #21262d;
border-bottom: 1px solid #30363d;
}
.header-label {
font-size: 11px;
font-weight: 700;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.header-actions { display: flex; gap: 4px; }
.action-btn {
padding: 4px;
border-radius: 4px;
color: #484f58;
background: none;
border: none;
cursor: pointer;
transition: color 0.15s;
}
.action-btn:hover { color: #e6edf3; }
.file-tree-body { padding: 6px 4px; }
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
color: #8b949e;
transition: background 0.12s, color 0.12s;
font-size: 12px;
}
.tree-row:hover { color: #e6edf3; background: rgba(255,255,255,0.03); }
.tree-row.active { background: #21262d; color: #e6edf3; }
.chevron {
flex-shrink: 0;
color: #484f58;
transition: transform 0.15s;
}
.chevron.open { transform: rotate(90deg); }
.chevron-spacer { width: 10px; flex-shrink: 0; }
.file-badge {
font-size: 8px;
font-weight: 700;
border-radius: 3px;
padding: 0 3px;
display: flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 14px;
line-height: 1;
}
.node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-name.modified { color: #e3b341; }
.git-mod { font-size: 9px; font-weight: 700; color: #e3b341; margin-left: auto; }
.git-untracked { font-size: 9px; font-weight: 700; color: #4ade80; margin-left: auto; }
.file-tree-footer {
padding: 0.5rem 0.75rem;
border-top: 1px solid #30363d;
background: #0d1117;
}
.footer-path { font-size: 10px; color: #484f58; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend { display: flex; gap: 12px; margin-top: 8px; font-size: 10px; color: #484f58; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.git-mod-inline { color: #e3b341; font-weight: 700; }
.git-untracked-inline { color: #4ade80; font-weight: 700; }
</style><script>
const TREE = [
{
id: "src",
name: "src",
type: "folder",
children: [
{
id: "components",
name: "components",
type: "folder",
children: [
{ id: "button", name: "Button.tsx", type: "tsx" },
{ id: "input", name: "Input.tsx", type: "tsx", modified: true },
{ id: "modal", name: "Modal.tsx", type: "tsx" },
{ id: "nav", name: "Navbar.tsx", type: "tsx" },
],
},
{
id: "hooks",
name: "hooks",
type: "folder",
children: [
{ id: "useauth", name: "useAuth.ts", type: "ts" },
{ id: "usefetch", name: "useFetch.ts", type: "ts", untracked: true },
],
},
{
id: "lib",
name: "lib",
type: "folder",
children: [
{ id: "utils", name: "utils.ts", type: "ts" },
{ id: "api", name: "api.ts", type: "ts", modified: true },
],
},
{ id: "apptsx", name: "App.tsx", type: "tsx" },
{ id: "maintsx", name: "main.tsx", type: "tsx" },
],
},
{
id: "public",
name: "public",
type: "folder",
children: [
{ id: "indexhtml", name: "index.html", type: "md" },
{ id: "favicon", name: "favicon.svg", type: "env" },
],
},
{ id: "pkgjson", name: "package.json", type: "json" },
{ id: "tsconfigjson", name: "tsconfig.json", type: "json" },
{ id: "tailwindcfg", name: "tailwind.config.js", type: "js" },
{ id: "envlocal", name: ".env.local", type: "env", untracked: true },
{ id: "gitignore", name: ".gitignore", type: "git" },
{ id: "readme", name: "README.md", type: "md", modified: true },
];
const TYPE_ICON = {
folder: { color: "#e3b341", label: "" },
ts: { color: "#3178c6", label: "TS" },
tsx: { color: "#61dafb", label: "TSX" },
js: { color: "#f7df1e", label: "JS" },
jsx: { color: "#61dafb", label: "JSX" },
json: { color: "#ffca28", label: "{}" },
css: { color: "#264de4", label: "CS" },
md: { color: "#ffffff", label: "MD" },
env: { color: "#7ee787", label: "EN" },
git: { color: "#f05032", label: "GI" },
lock: { color: "#8b949e", label: "LK" },
};
function getInitialExpanded(nodes, max = 1, depth = 0) {
if (depth >= max) return [];
const ids = [];
for (const n of nodes) {
if (n.type === "folder") {
ids.push(n.id);
if (n.children) ids.push(...getInitialExpanded(n.children, max, depth + 1));
}
}
return ids;
}
let activeId = "apptsx";
let expandedIds = new Set(getInitialExpanded(TREE));
function toggle(id) {
const next = new Set(expandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds = next;
}
function handleClick(node) {
if (node.type === "folder") toggle(node.id);
activeId = node.id;
}
function expandAll() {
const all = [];
function collect(nodes) {
for (const n of nodes) {
if (n.type === "folder") {
all.push(n.id);
if (n.children) collect(n.children);
}
}
}
collect(TREE);
expandedIds = new Set(all);
}
function collapseAll() {
expandedIds = new Set();
}
function flattenTree(nodes, depth) {
const rows = [];
for (const node of nodes) {
const isFolder = node.type === "folder";
const isOpen = isFolder && expandedIds.has(node.id);
rows.push({ node, depth, isFolder, isOpen });
if (isFolder && isOpen && node.children) {
rows.push(...flattenTree(node.children, depth + 1));
}
}
return rows;
}
$: flatList = flattenTree(TREE, 0);
$: activePath = activeId ? "/" + activeId.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) : "";
</script>
<div class="file-tree-wrapper">
<div class="file-tree-panel">
<div class="file-tree-card">
<div class="file-tree-header">
<span class="header-label">Explorer</span>
<div class="header-actions">
<button on:click={expandAll} title="Expand all" class="action-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</button>
<button on:click={collapseAll} title="Collapse all" class="action-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
</button>
</div>
</div>
<div class="file-tree-body">
{#each flatList as row (row.node.id)}
<div
class="tree-row"
class:active={activeId === row.node.id}
style="padding-left: {row.depth * 16 + 8}px"
on:click={() => handleClick(row.node)}
role="button"
tabindex="0"
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(row.node); }}
>
{#if row.isFolder}
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5"
class="chevron" class:open={row.isOpen}
>
<polyline points="9 18 15 12 9 6"/>
</svg>
{:else}
<span class="chevron-spacer"></span>
{/if}
{#if row.isFolder}
<svg width="14" height="14" viewBox="0 0 24 24" fill="#e3b341" stroke="none" opacity="0.9">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/>
</svg>
{:else}
<span
class="file-badge"
style="color: {TYPE_ICON[row.node.type].color}; background: {TYPE_ICON[row.node.type].color}22; border: 1px solid {TYPE_ICON[row.node.type].color}44;"
>
{TYPE_ICON[row.node.type].label}
</span>
{/if}
<span class="node-name" class:modified={row.node.modified}>{row.node.name}</span>
{#if row.node.modified}
<span class="git-mod">M</span>
{/if}
{#if row.node.untracked}
<span class="git-untracked">U</span>
{/if}
</div>
{/each}
</div>
{#if activeId}
<div class="file-tree-footer">
<p class="footer-path">{activePath}</p>
</div>
{/if}
</div>
<div class="legend">
<span class="legend-item"><span class="git-mod-inline">M</span> Modified</span>
<span class="legend-item"><span class="git-untracked-inline">U</span> Untracked</span>
</div>
</div>
</div>
<style>
.file-tree-wrapper {
min-height: 100vh;
background: #0d1117;
padding: 1.5rem;
display: flex;
justify-content: center;
}
.file-tree-panel { width: 100%; max-width: 360px; }
.file-tree-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
overflow: hidden;
}
.file-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: #21262d;
border-bottom: 1px solid #30363d;
}
.header-label {
font-size: 11px;
font-weight: 700;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.header-actions { display: flex; gap: 4px; }
.action-btn {
padding: 4px;
border-radius: 4px;
color: #484f58;
background: none;
border: none;
cursor: pointer;
transition: color 0.15s;
}
.action-btn:hover { color: #e6edf3; }
.file-tree-body { padding: 6px 4px; }
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
color: #8b949e;
transition: background 0.12s, color 0.12s;
font-size: 12px;
}
.tree-row:hover { color: #e6edf3; background: rgba(255,255,255,0.03); }
.tree-row.active { background: #21262d; color: #e6edf3; }
.chevron {
flex-shrink: 0;
color: #484f58;
transition: transform 0.15s;
}
.chevron.open { transform: rotate(90deg); }
.chevron-spacer { width: 10px; flex-shrink: 0; }
.file-badge {
font-size: 8px;
font-weight: 700;
border-radius: 3px;
padding: 0 3px;
display: flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 14px;
line-height: 1;
}
.node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-name.modified { color: #e3b341; }
.git-mod { font-size: 9px; font-weight: 700; color: #e3b341; margin-left: auto; }
.git-untracked { font-size: 9px; font-weight: 700; color: #4ade80; margin-left: auto; }
.file-tree-footer {
padding: 0.5rem 0.75rem;
border-top: 1px solid #30363d;
background: #0d1117;
}
.footer-path { font-size: 10px; color: #484f58; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend { display: flex; gap: 12px; margin-top: 8px; font-size: 10px; color: #484f58; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.git-mod-inline { color: #e3b341; font-weight: 700; }
.git-untracked-inline { color: #4ade80; font-weight: 700; }
</style>VS Code-style file explorer tree with nested folder/file icons, animated expand/collapse, keyboard navigation, active file highlighting, and a right-click context menu (rename, delete, new file).