workspace: seed all bootstrap files + DuckDB on create, add directory picker

This commit is contained in:
kumarabhirup 2026-02-19 21:38:36 -08:00
parent 21f60da24d
commit d43d226ee8
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 809 additions and 41 deletions

View File

@ -1,38 +1,239 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot } from "@/lib/workspace";
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const BOOTSTRAP_FILES: Record<string, string> = {
"AGENTS.md": `# Workspace Agent Instructions
// ---------------------------------------------------------------------------
// Bootstrap file names (must match src/agents/workspace.ts)
// ---------------------------------------------------------------------------
Add instructions here that your agent should follow when working in this workspace.
`,
"SOUL.md": `# Soul
const BOOTSTRAP_FILENAMES = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
] as const;
Describe the personality and behavior of your agent here.
`,
"USER.md": `# User
Describe yourself your preferences, context, and how you'd like the agent to interact with you.
`,
// Minimal fallback content used when templates can't be loaded from disk
const FALLBACK_CONTENT: Record<string, string> = {
"AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
"SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
"TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
"USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
"HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
};
// ---------------------------------------------------------------------------
// CRM seed objects (mirrors src/agents/workspace-seed.ts)
// ---------------------------------------------------------------------------
type SeedField = {
name: string;
type: string;
required?: boolean;
enumValues?: string[];
};
type SeedObject = {
id: string;
name: string;
description: string;
icon: string;
defaultView: string;
entryCount: number;
fields: SeedField[];
};
const SEED_OBJECTS: SeedObject[] = [
{
id: "seed_obj_people_00000000000000",
name: "people",
description: "Contact management",
icon: "users",
defaultView: "table",
entryCount: 5,
fields: [
{ name: "Full Name", type: "text", required: true },
{ name: "Email Address", type: "email", required: true },
{ name: "Phone Number", type: "phone" },
{ name: "Company", type: "text" },
{ name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
{ name: "Notes", type: "richtext" },
],
},
{
id: "seed_obj_company_0000000000000",
name: "company",
description: "Company tracking",
icon: "building-2",
defaultView: "table",
entryCount: 3,
fields: [
{ name: "Company Name", type: "text", required: true },
{
name: "Industry",
type: "enum",
enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
},
{ name: "Website", type: "text" },
{ name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
{ name: "Notes", type: "richtext" },
],
},
{
id: "seed_obj_task_000000000000000",
name: "task",
description: "Task tracking board",
icon: "check-square",
defaultView: "kanban",
entryCount: 5,
fields: [
{ name: "Title", type: "text", required: true },
{ name: "Description", type: "text" },
{ name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
{ name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
{ name: "Due Date", type: "date" },
{ name: "Notes", type: "richtext" },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripFrontMatter(content: string): string {
if (!content.startsWith("---")) {return content;}
const endIndex = content.indexOf("\n---", 3);
if (endIndex === -1) {return content;}
return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
}
/** Try multiple candidate paths to find the monorepo root. */
function resolveProjectRoot(): string | null {
const marker = join("docs", "reference", "templates", "AGENTS.md");
const cwd = process.cwd();
// CWD is the repo root (standalone builds)
if (existsSync(join(cwd, marker))) {return cwd;}
// CWD is apps/web/ (dev mode)
const fromApps = resolve(cwd, "..", "..");
if (existsSync(join(fromApps, marker))) {return fromApps;}
return null;
}
function loadTemplateContent(filename: string, projectRoot: string | null): string {
if (projectRoot) {
const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
try {
const raw = readFileSync(templatePath, "utf-8");
return stripFrontMatter(raw);
} catch {
// fall through to fallback
}
}
return FALLBACK_CONTENT[filename] ?? "";
}
function generateObjectYaml(obj: SeedObject): string {
const lines: string[] = [
`id: "${obj.id}"`,
`name: "${obj.name}"`,
`description: "${obj.description}"`,
`icon: "${obj.icon}"`,
`default_view: "${obj.defaultView}"`,
`entry_count: ${obj.entryCount}`,
"fields:",
];
for (const field of obj.fields) {
lines.push(` - name: "${field.name}"`);
lines.push(` type: ${field.type}`);
if (field.required) {lines.push(" required: true");}
if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
}
return lines.join("\n") + "\n";
}
function generateWorkspaceMd(objects: SeedObject[]): string {
const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
for (const obj of objects) {
lines.push(`## ${obj.name}`, "");
lines.push(`- **Description**: ${obj.description}`);
lines.push(`- **View**: \`${obj.defaultView}\``);
lines.push(`- **Entries**: ${obj.entryCount}`);
lines.push("- **Fields**:");
for (const field of obj.fields) {
const req = field.required ? " (required)" : "";
const vals = field.enumValues ? `${field.enumValues.join(", ")}` : "";
lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
}
lines.push("");
}
return lines.join("\n");
}
function writeIfMissing(filePath: string, content: string): boolean {
if (existsSync(filePath)) {return false;}
try {
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
return true;
} catch {
return false;
}
}
function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
const destPath = join(workspaceDir, "workspace.duckdb");
if (existsSync(destPath)) {return false;}
if (!projectRoot) {return false;}
const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
if (!existsSync(seedDb)) {return false;}
try {
copyFileSync(seedDb, destPath);
} catch {
return false;
}
// Create filesystem projections for CRM objects
for (const obj of SEED_OBJECTS) {
const objDir = join(workspaceDir, obj.name);
mkdirSync(objDir, { recursive: true });
writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
}
writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
return true;
}
// ---------------------------------------------------------------------------
// Route handler
// ---------------------------------------------------------------------------
export async function POST(req: Request) {
const body = (await req.json()) as {
profile?: string;
/** Absolute path override (optional; defaults to profile-based resolution). */
path?: string;
/** Seed bootstrap files into the new workspace. Default true. */
seedBootstrap?: boolean;
};
const profileName = body.profile?.trim() || null;
// Validate profile name if provided
if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
return Response.json(
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
@ -56,7 +257,6 @@ export async function POST(req: Request) {
}
}
// Create the workspace directory
try {
mkdirSync(workspaceDir, { recursive: true });
} catch (err) {
@ -66,24 +266,57 @@ export async function POST(req: Request) {
);
}
// Seed bootstrap files
const seedBootstrap = body.seedBootstrap !== false;
const seeded: string[] = [];
if (seedBootstrap) {
for (const [filename, content] of Object.entries(BOOTSTRAP_FILES)) {
const projectRoot = resolveProjectRoot();
// Seed all bootstrap files from templates
for (const filename of BOOTSTRAP_FILENAMES) {
const filePath = join(workspaceDir, filename);
if (!existsSync(filePath)) {
try {
writeFileSync(filePath, content, "utf-8");
const content = loadTemplateContent(filename, projectRoot);
if (writeIfMissing(filePath, content)) {
seeded.push(filename);
} catch {
// Skip files that can't be written (permissions, etc.)
}
}
}
// Seed DuckDB + CRM object projections
if (seedDuckDB(workspaceDir, projectRoot)) {
seeded.push("workspace.duckdb");
for (const obj of SEED_OBJECTS) {
seeded.push(`${obj.name}/.object.yaml`);
}
}
// Write workspace state so the gateway knows seeding was done
const stateDir = join(workspaceDir, ".openclaw");
const statePath = join(stateDir, "workspace-state.json");
if (!existsSync(statePath)) {
try {
mkdirSync(stateDir, { recursive: true });
const state = {
version: 1,
bootstrapSeededAt: new Date().toISOString(),
duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
? new Date().toISOString()
: undefined,
};
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
} catch {
// Best-effort state tracking
}
}
}
// If a profile was specified, switch to it
// Remember custom-path workspaces in the registry
if (body.path?.trim() && profileName) {
registerWorkspacePath(profileName, workspaceDir);
}
// Switch to the new profile
if (profileName) {
setUIActiveProfile(profileName === "default" ? null : profileName);
}

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { DirectoryPickerModal } from "./directory-picker-modal";
type CreateWorkspaceDialogProps = {
isOpen: boolean;
@ -8,10 +9,15 @@ type CreateWorkspaceDialogProps = {
onCreated?: () => void;
};
function shortenPath(p: string): string {
return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
}
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
const [profileName, setProfileName] = useState("");
const [customPath, setCustomPath] = useState("");
const [useCustomPath, setUseCustomPath] = useState(false);
const [showDirPicker, setShowDirPicker] = useState(false);
const [seedBootstrap, setSeedBootstrap] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -25,22 +31,23 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
setProfileName("");
setCustomPath("");
setUseCustomPath(false);
setShowDirPicker(false);
setError(null);
setResult(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Close on Escape
// Close on Escape (only if dir picker is not open)
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {onClose();}
if (e.key === "Escape" && !showDirPicker) {onClose();}
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen, onClose]);
}, [isOpen, onClose, showDirPicker]);
const handleCreate = async () => {
const name = profileName.trim();
@ -222,18 +229,65 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
</button>
{useCustomPath && (
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="~/my-workspace or /absolute/path"
className="w-full mt-2 px-3 py-2 text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
}}
/>
<div className="mt-2 space-y-2">
{customPath ? (
<div
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{customPath.split("/").pop()}
</p>
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
{shortenPath(customPath)}
</p>
</div>
<button
onClick={() => setShowDirPicker(true)}
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
style={{ color: "var(--color-accent)" }}
>
Change
</button>
<button
onClick={() => setCustomPath("")}
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
) : (
<button
onClick={() => setShowDirPicker(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
style={{
background: "var(--color-bg)",
border: "1px dashed var(--color-border-strong)",
color: "var(--color-text-muted)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
Browse for a directory...
</button>
)}
</div>
)}
</div>
@ -250,7 +304,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
className="text-sm"
style={{ color: "var(--color-text-secondary)" }}
>
Seed bootstrap files (AGENTS.md, SOUL.md, USER.md)
Seed bootstrap files and workspace database
</span>
</label>
@ -309,6 +363,13 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
)}
</div>
</div>
{/* Directory picker modal */}
<DirectoryPickerModal
open={showDirPicker}
onClose={() => setShowDirPicker(false)}
onSelect={(path) => setCustomPath(path)}
/>
</div>
);
}

View File

@ -0,0 +1,474 @@
"use client";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
type BrowseEntry = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
type DirectoryPickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (path: string) => void;
/** Starting directory (absolute). Falls back to the workspace root / home. */
startDir?: string;
};
function buildBreadcrumbs(dir: string): { label: string; path: string }[] {
const segments: { label: string; path: string }[] = [];
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
const homeDir = homeMatch?.[1];
if (homeDir) {
segments.push({ label: "~", path: homeDir });
const rest = dir.slice(homeDir.length);
const parts = rest.split("/").filter(Boolean);
let currentPath = homeDir;
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
} else if (dir === "/") {
segments.push({ label: "/", path: "/" });
} else {
segments.push({ label: "/", path: "/" });
const parts = dir.split("/").filter(Boolean);
let currentPath = "";
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
}
return segments;
}
const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" };
function FolderIcon({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
);
}
export function DirectoryPickerModal({
open,
onClose,
onSelect,
startDir,
}: DirectoryPickerModalProps) {
const [currentDir, setCurrentDir] = useState<string | null>(startDir ?? null);
const [displayDir, setDisplayDir] = useState("");
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [parentDir, setParentDir] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
} else {
setVisible(false);
}
}, [open]);
useEffect(() => {
if (!open) {
setSearch("");
setCreatingFolder(false);
setNewFolderName("");
setError(null);
}
}, [open]);
// Reset to startDir when reopening
useEffect(() => {
if (open) {
setCurrentDir(startDir ?? null);
}
}, [open, startDir]);
const searchRef = useRef<HTMLInputElement>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
const fetchDir = useCallback(async (dir: string | null) => {
setLoading(true);
setError(null);
try {
const url = dir
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
: "/api/workspace/browse";
const res = await fetch(url);
if (!res.ok) {throw new Error("Failed to list directory");}
const data = await res.json();
setEntries(data.entries || []);
setDisplayDir(data.currentDir || "");
setParentDir(data.parentDir ?? null);
} catch {
setError("Could not load this directory");
setEntries([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) { void fetchDir(currentDir); }
}, [open, currentDir, fetchDir]);
useEffect(() => {
if (!open) {return;}
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
const navigateInto = useCallback((path: string) => {
setCurrentDir(path);
setSearch("");
setCreatingFolder(false);
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim() || !displayDir) {return;}
const folderPath = `${displayDir}/${newFolderName.trim()}`;
try {
await fetch("/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: folderPath }),
});
setCreatingFolder(false);
setNewFolderName("");
void fetchDir(currentDir);
} catch {
setError("Failed to create folder");
}
}, [newFolderName, displayDir, currentDir, fetchDir]);
const handleSelectCurrent = useCallback(() => {
if (displayDir) {
onSelect(displayDir);
onClose();
}
}, [displayDir, onSelect, onClose]);
// Only show folders
const folders = entries
.filter((e) => e.type === "folder")
.filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase()))
.toSorted((a, b) => a.name.localeCompare(b.name));
const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : [];
// Shorten display path for the footer
const shortDir = displayDir
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~");
if (!open) {return null;}
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center"
style={{
opacity: visible ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
>
<div
className="absolute inset-0"
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
onClick={onClose}
/>
<div
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
style={{
maxHeight: "70vh",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
transform: visible ? "scale(1)" : "scale(0.97)",
transition: "transform 150ms ease-out",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon size={18} />
</div>
<div>
<h2 className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
Choose Directory
</h2>
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Navigate to a folder for the workspace
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Breadcrumbs */}
{displayDir && (
<div
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
style={{ borderColor: "var(--color-border)", scrollbarWidth: "thin" }}
>
{breadcrumbs.map((seg, i) => (
<Fragment key={seg.path}>
{i > 0 && (
<span
className="text-[10px] flex-shrink-0"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
/
</span>
)}
<button
type="button"
onClick={() => navigateInto(seg.path)}
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
style={{
color: i === breadcrumbs.length - 1
? "var(--color-text)"
: "var(--color-text-muted)",
}}
>
{seg.label}
</button>
</Fragment>
))}
</div>
)}
{/* Search + New Folder */}
<div
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: "var(--color-bg)", border: "1px solid var(--color-border)" }}
>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter folders..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
style={{ color: "var(--color-text)" }}
/>
</div>
<button
type="button"
onClick={() => {
setCreatingFolder(true);
setTimeout(() => newFolderRef.current?.focus(), 50);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
New Folder
</button>
</div>
{/* Folder list */}
<div
className="flex-1 overflow-y-auto"
style={{ background: "var(--color-bg)", minHeight: 200 }}
>
{loading ? (
<div className="flex items-center justify-center py-16">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
{error}
</div>
) : (
<>
{/* Go up */}
{parentDir && (
<button
type="button"
onClick={() => navigateInto(parentDir)}
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: "var(--color-surface-hover)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
</div>
<span className="text-[13px] font-medium">..</span>
</button>
)}
{/* New folder input */}
{creatingFolder && (
<div className="flex items-center gap-3 px-4 py-2">
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<input
ref={newFolderRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {void handleCreateFolder();}
if (e.key === "Escape") {
setCreatingFolder(false);
setNewFolderName("");
}
}}
placeholder="Folder name..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
style={{
color: "var(--color-text)",
background: "var(--color-surface)",
border: "1px solid var(--color-accent)",
}}
/>
</div>
)}
{/* Folder entries */}
{folders.length === 0 && !parentDir && (
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
No subfolders here
</div>
)}
{folders.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => navigateInto(entry.path)}
className="w-full flex items-center gap-3 px-4 py-1.5 group text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<span
className="flex-1 text-[13px] font-medium truncate"
style={{ color: "var(--color-text)" }}
title={entry.path}
>
{entry.name}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
))}
</>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="min-w-0 flex-1 mr-3">
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={displayDir}>
{shortDir || "Loading..."}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleSelectCurrent}
disabled={!displayDir}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
color: "white",
background: displayDir ? "var(--color-accent)" : "var(--color-border-strong)",
}}
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
);
}