workspace: seed all bootstrap files + DuckDB on create, add directory picker
This commit is contained in:
parent
21f60da24d
commit
d43d226ee8
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
474
apps/web/app/components/workspace/directory-picker-modal.tsx
Normal file
474
apps/web/app/components/workspace/directory-picker-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user