molecule-core/canvas/src/components/tabs/FilesTab/FileTree.tsx
Hongming Wang c0eca8d0e1 feat(canvas): warm-paper theme + Tailwind v4 migration
Brings the canvas onto the warm-paper design system already shipped to
landing, marketplace, and SaaS surfaces, and migrates the build from
Tailwind v3 → v4 to match molecule-app.

Plumbing:
- swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss
- delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS)
- globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography"
- two @theme blocks: warm-paper light defaults + always-dark surface
  tokens (bg-bg / ink-mute / line-strong) for terminal/console panels
- [data-theme="dark"] cascade overrides the warm-paper tokens for dark
- React Flow edge stroke + scrollbar + selection colour pull from
  semantic tokens so they flip with the theme

Theme infra (ported from molecule-app, identical contracts):
- lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client"
  so server components can read the constants)
- lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with
  Domain=.moleculesai.app so the preference follows the user across
  canvas/app/market/landing subdomains AND tenant subdomains
- lib/theme.ts: ColorToken union + cssVar() helper
- components/ThemeToggle.tsx: 3-way System/Light/Dark picker
- layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs
  the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd
  inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body

Component migration (62 files):
- Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white →
  semantic surface/ink/line tokens via perl negative-lookahead pass
  (preserves opacity modifiers like /80, /60)
- bg-blue-500/600 → bg-accent / bg-accent-strong
- text-red-* / amber-* / emerald-* → text-bad / warm / good
- Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950
  etc.) intentionally left literal — they remain readable on warm-paper
  in light mode without inventing new state-soft tokens
- TerminalTab.tsx skipped — xterm renders to canvas, not DOM
- 3 unit-test assertions updated to match new token strings (credits
  pillTone, AuthGate overlay class, A2AEdge accent)

Verification:
- pnpm test: 1214/1214 pass
- pnpm tsc --noEmit: clean
- next build: ✓ Compiled successfully (8 routes)
- dev server inspection: html data-theme stamped, body uses
  bg-surface text-ink, boot script carries nonce, compiled CSS
  contains both @theme blocks + [data-theme="dark"] override

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:43:55 -07:00

118 lines
3.2 KiB
TypeScript

"use client";
import { type TreeNode, getIcon } from "./tree";
interface TreeCallbacks {
selectedPath: string | null;
onSelect: (path: string) => void;
onDelete: (path: string) => void;
expandedDirs: Set<string>;
onToggleDir: (path: string) => void;
loadingDir: string | null;
}
export function FileTree({
nodes,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth = 0,
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
return (
<div>
{nodes.map((node) => (
<TreeItem
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth}
/>
))}
</div>
);
}
function TreeItem({
node,
selectedPath,
onSelect,
onDelete,
expandedDirs,
onToggleDir,
loadingDir,
depth,
}: TreeCallbacks & { node: TreeNode; depth: number }) {
const isSelected = selectedPath === node.path;
const expanded = expandedDirs.has(node.path);
const isLoading = loadingDir === node.path;
if (node.isDir) {
return (
<div>
<div
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
>
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
<button
aria-label={`Delete ${node.name}`}
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
>
</button>
</div>
{expanded && (
<FileTree
nodes={node.children}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
depth={depth + 1}
/>
)}
</div>
);
}
return (
<div
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
isSelected ? "bg-blue-900/30 text-ink" : "hover:bg-surface-card/40 text-ink-mid"
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<span className="text-[9px]">{getIcon(node.name, false)}</span>
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
<button
aria-label={`Delete ${node.name}`}
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
>
</button>
</div>
);
}