web: restore functional features dropped by design merge
Restore backend/logic features that were incorrectly auto-merged from the design branch: - Spreadsheet viewer (xlsx, csv, ods, etc.) and xlsx dependency - HTML iframe viewer with source toggle - Directory picker modal for workspace creation - Workspace registry for custom-path workspaces - Session auto-discovery for orphaned sessions - Workspace init seeding (CRM objects, DuckDB, templates, bootstrap files) - Symlink resolution and showHidden in tree/browse routes - Upload to workspace assets/ instead of hidden ~/.ironclaw/uploads/ - Webpack dev watcher config (next.config.ts) - router.push for back-button navigation history
This commit is contained in:
parent
db4c90b37d
commit
109b88b93c
@ -1,28 +1,9 @@
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type IndexEntry = { id: string; [k: string]: unknown };
|
||||
|
||||
function readIndex(): IndexEntry[] {
|
||||
const dir = resolveWebChatDir();
|
||||
const indexFile = join(dir, "index.json");
|
||||
if (!existsSync(indexFile)) { return []; }
|
||||
try {
|
||||
return JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(sessions: IndexEntry[]) {
|
||||
const dir = resolveWebChatDir();
|
||||
if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); }
|
||||
writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2));
|
||||
}
|
||||
|
||||
export type ChatLine = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
@ -63,48 +44,3 @@ export async function GET(
|
||||
|
||||
return Response.json({ id, messages });
|
||||
}
|
||||
|
||||
/** PATCH /api/web-sessions/[id] — update session metadata (e.g. rename). */
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
let body: { title?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const sessions = readIndex();
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
if (!session) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
if (typeof body.title === "string") {
|
||||
session.title = body.title;
|
||||
}
|
||||
writeIndex(sessions);
|
||||
return Response.json({ ok: true, session });
|
||||
}
|
||||
|
||||
/** DELETE /api/web-sessions/[id] — remove a web chat session and its messages. */
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const dir = resolveWebChatDir();
|
||||
const filePath = join(dir, `${id}.jsonl`);
|
||||
|
||||
const sessions = readIndex();
|
||||
const filtered = sessions.filter((s) => s.id !== id);
|
||||
if (filtered.length === sessions.length) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
writeIndex(filtered);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
@ -23,15 +23,68 @@ function ensureDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session index, auto-discovering any orphaned .jsonl files
|
||||
* that aren't in the index (e.g. from profile switches or missing index).
|
||||
*/
|
||||
function readIndex(): WebSessionMeta[] {
|
||||
const dir = ensureDir();
|
||||
const indexFile = join(dir, "index.json");
|
||||
if (!existsSync(indexFile)) {return [];}
|
||||
try {
|
||||
return JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
let index: WebSessionMeta[] = [];
|
||||
if (existsSync(indexFile)) {
|
||||
try {
|
||||
index = JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
index = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for orphaned .jsonl files not in the index
|
||||
try {
|
||||
const indexed = new Set(index.map((s) => s.id));
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
||||
let dirty = false;
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.jsonl$/, "");
|
||||
if (indexed.has(id)) {continue;}
|
||||
|
||||
// Build a minimal index entry from the file
|
||||
const fp = join(dir, file);
|
||||
const stat = statSync(fp);
|
||||
let title = "New Chat";
|
||||
let messageCount = 0;
|
||||
try {
|
||||
const content = readFileSync(fp, "utf-8");
|
||||
const lines = content.split("\n").filter((l) => l.trim());
|
||||
messageCount = lines.length;
|
||||
// Try to extract a title from the first user message
|
||||
for (const line of lines) {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.role === "user" && parsed.content) {
|
||||
const text = String(parsed.content);
|
||||
title = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
index.push({
|
||||
id,
|
||||
title,
|
||||
createdAt: stat.birthtimeMs || stat.mtimeMs,
|
||||
updatedAt: stat.mtimeMs,
|
||||
messageCount,
|
||||
});
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
index.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
writeFileSync(indexFile, JSON.stringify(index, null, 2));
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function writeIndex(sessions: WebSessionMeta[]) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { readdirSync, type Dirent } from "node:fs";
|
||||
import { readdirSync, statSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
@ -10,16 +10,34 @@ type BrowseNode = {
|
||||
path: string; // absolute path
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
children?: BrowseNode[];
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/** Directories to skip when browsing the filesystem. */
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build a depth-limited tree from an absolute directory. */
|
||||
function buildBrowseTree(
|
||||
absDir: string,
|
||||
maxDepth: number,
|
||||
currentDepth = 0,
|
||||
showHidden = false,
|
||||
): BrowseNode[] {
|
||||
if (currentDepth >= maxDepth) {return [];}
|
||||
|
||||
@ -30,29 +48,43 @@ function buildBrowseTree(
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith("."))
|
||||
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
const filtered = entries
|
||||
.filter((e) => showHidden || !e.name.startsWith("."))
|
||||
.filter((e) => {
|
||||
const absPath = join(absDir, e.name);
|
||||
const t = resolveEntryType(e, absPath);
|
||||
return !(t === "directory" && SKIP_DIRS.has(e.name));
|
||||
});
|
||||
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const nodes: BrowseNode[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
const absPath = join(absDir, entry.name);
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
|
||||
if (effectiveType === "directory") {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else if (entry.isFile()) {
|
||||
} else if (effectiveType === "file") {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
@ -61,6 +93,7 @@ function buildBrowseTree(
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -71,8 +104,8 @@ function buildBrowseTree(
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
let dir = url.searchParams.get("dir");
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
// Default to the workspace root
|
||||
if (!dir) {
|
||||
dir = resolveWorkspaceRoot();
|
||||
}
|
||||
@ -83,10 +116,9 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve and normalize the directory path
|
||||
const resolved = resolve(dir);
|
||||
|
||||
const entries = buildBrowseTree(resolved, 3);
|
||||
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
|
||||
const parentDir = resolved === "/" ? null : dirname(resolved);
|
||||
|
||||
return Response.json({
|
||||
|
||||
@ -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,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
@ -14,6 +14,8 @@ export type TreeNode = {
|
||||
children?: TreeNode[];
|
||||
/** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
@ -58,11 +60,28 @@ function loadDbObjects(): Map<string, DbObject> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink -- skip
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively build a tree from a workspace directory. */
|
||||
function buildTree(
|
||||
absDir: string,
|
||||
relativeBase: string,
|
||||
dbObjects: Map<string, DbObject>,
|
||||
showHidden = false,
|
||||
): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
@ -73,32 +92,44 @@ function buildTree(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filtered = entries.filter((e) => {
|
||||
// .object.yaml is always needed for metadata; also shown as a node when showHidden is on
|
||||
if (e.name === ".object.yaml") {return true;}
|
||||
if (e.name.startsWith(".")) {return showHidden;}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetical within each group
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml")
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of sorted) {
|
||||
// Skip hidden files except .object.yaml (but don't list it as a node)
|
||||
if (entry.name === ".object.yaml") {continue;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
// .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
|
||||
if (entry.name === ".object.yaml" && !showHidden) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relativeBase
|
||||
? `${relativeBase}/${entry.name}`
|
||||
: entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
const objectMeta = readObjectMeta(absPath);
|
||||
const dbObject = dbObjects.get(entry.name);
|
||||
const children = buildTree(absPath, relPath, dbObjects);
|
||||
const children = buildTree(absPath, relPath, dbObjects, showHidden);
|
||||
|
||||
if (objectMeta || dbObject) {
|
||||
// This directory represents a CRM object (from .object.yaml OR DuckDB)
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
@ -109,17 +140,18 @@ function buildTree(
|
||||
| "table"
|
||||
| "kanban") ?? "table",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else {
|
||||
// Regular folder
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
} else if (effectiveType === "file") {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
@ -129,6 +161,7 @@ function buildTree(
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -206,27 +239,24 @@ function buildSkillsVirtualFolder(): TreeNode | null {
|
||||
}
|
||||
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
// Even without a workspace, return virtual folders if they exist
|
||||
const tree: TreeNode[] = [];
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
// Scan the workspace root — it IS the knowledge base.
|
||||
// All top-level directories, files, objects, and documents are visible
|
||||
// in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree).
|
||||
const tree = buildTree(root, "", dbObjects);
|
||||
const tree = buildTree(root, "", dbObjects, showHidden);
|
||||
|
||||
// Virtual folders go after all real files/folders
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
|
||||
|
||||
@ -1,21 +1,27 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/** Hidden uploads dir in the user's home directory — persists forever, invisible to users. */
|
||||
const UPLOADS_DIR = join(homedir(), ".ironclaw", "uploads");
|
||||
|
||||
/**
|
||||
* POST /api/workspace/upload
|
||||
* Accepts multipart form data with a "file" field.
|
||||
* Saves to a temp directory and returns the absolute path.
|
||||
* Saves to assets/<timestamp>-<filename> inside the workspace.
|
||||
* Returns { ok, path } where path is workspace-relative.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
return Response.json(
|
||||
{ error: "Workspace not found" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
@ -43,13 +49,21 @@ export async function POST(req: Request) {
|
||||
const safeName = file.name
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_{2,}/g, "_");
|
||||
const absPath = join(UPLOADS_DIR, `${Date.now()}-${safeName}`);
|
||||
const relPath = join("assets", `${Date.now()}-${safeName}`);
|
||||
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
writeFileSync(absPath, buffer);
|
||||
return Response.json({ ok: true, path: absPath });
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Upload failed" },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,53 +1,36 @@
|
||||
"use client";
|
||||
|
||||
type FileViewerProps = {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: "yaml" | "text";
|
||||
};
|
||||
import { useState, useEffect } from "react";
|
||||
import { read, utils, type WorkBook } from "xlsx";
|
||||
|
||||
export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
"xlsx", "xls", "xlsb", "xlsm", "xltx", "xltm",
|
||||
"ods", "fods",
|
||||
"csv", "tsv",
|
||||
"numbers",
|
||||
]);
|
||||
|
||||
export function isSpreadsheetFile(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return SPREADSHEET_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
type FileViewerProps =
|
||||
| { content: string; filename: string; type: "yaml" | "text" }
|
||||
| { filename: string; type: "spreadsheet"; url: string; content?: never };
|
||||
|
||||
export function FileViewer(props: FileViewerProps) {
|
||||
if (props.type === "spreadsheet") {
|
||||
return <SpreadsheetViewer filename={props.filename} url={props.url} />;
|
||||
}
|
||||
|
||||
const { content, filename, type } = props;
|
||||
const lines = content.split("\n");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* File header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<FileHeader filename={filename} label={type.toUpperCase()} />
|
||||
|
||||
{/* File content */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
@ -62,7 +45,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
{/* Line number */}
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
@ -75,7 +57,6 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Line content */}
|
||||
<span
|
||||
className="pr-4 flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
@ -95,6 +76,272 @@ export function FileViewer({ content, filename, type }: FileViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FileHeader({ filename, label, icon }: { filename: string; label: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{icon ?? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded ml-auto"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spreadsheet viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SpreadsheetViewer({ filename, url }: { filename: string; url: string }) {
|
||||
const [workbook, setWorkbook] = useState<WorkBook | null>(null);
|
||||
const [activeSheet, setActiveSheet] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWorkbook(null);
|
||||
setActiveSheet(0);
|
||||
setError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);}
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buf) => {
|
||||
if (cancelled) {return;}
|
||||
const wb = read(buf, { type: "array" });
|
||||
setWorkbook(wb);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {setError(String(err));}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
const ext = filename.split(".").pop()?.toUpperCase() ?? "SPREADSHEET";
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Failed to load spreadsheet: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workbook) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
<div
|
||||
className="rounded-b-lg border p-8 text-center"
|
||||
style={{ background: "var(--color-bg)", borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Loading spreadsheet...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetNames = workbook.SheetNames;
|
||||
const sheet = workbook.Sheets[sheetNames[activeSheet]];
|
||||
const rows: string[][] = sheet ? utils.sheet_to_json(sheet, { header: 1, defval: "" }) : [];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<FileHeader filename={filename} label={ext} icon={<SpreadsheetIcon />} />
|
||||
|
||||
{/* Sheet tabs */}
|
||||
{sheetNames.length > 1 && (
|
||||
<div
|
||||
className="flex gap-0 border-x overflow-x-auto"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{sheetNames.map((name, idx) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setActiveSheet(idx)}
|
||||
className="px-4 py-1.5 text-xs font-medium whitespace-nowrap border-b-2 transition-colors"
|
||||
style={{
|
||||
background: idx === activeSheet ? "var(--color-bg)" : "var(--color-surface)",
|
||||
color: idx === activeSheet ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
borderBottomColor: idx === activeSheet ? "var(--color-accent)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
className="rounded-b-lg border overflow-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
maxHeight: "70vh",
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
This sheet is empty.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number header */}
|
||||
<th
|
||||
className="sticky top-0 z-10 px-3 py-2 text-right select-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
minWidth: "3rem",
|
||||
}}
|
||||
/>
|
||||
{rows[0]?.map((_cell, colIdx) => (
|
||||
<th
|
||||
key={colIdx}
|
||||
className="sticky top-0 z-10 px-3 py-2 text-left font-medium whitespace-nowrap"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{columnLabel(colIdx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<td
|
||||
className="px-3 py-1.5 text-right select-none tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{row.map((cell, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="px-3 py-1.5 whitespace-pre-wrap"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
maxWidth: "300px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 text-xs text-right"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
||||
{rows[0] ? ` \u00d7 ${rows[0].length} column${rows[0].length !== 1 ? "s" : ""}` : ""}
|
||||
{sheetNames.length > 1 ? ` \u00b7 ${sheetNames.length} sheets` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */
|
||||
function columnLabel(idx: number): string {
|
||||
let label = "";
|
||||
let n = idx;
|
||||
do {
|
||||
label = String.fromCharCode(65 + (n % 26)) + label;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
} while (n >= 0);
|
||||
return label;
|
||||
}
|
||||
|
||||
function SpreadsheetIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "#22c55e" }}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M8 13h2" />
|
||||
<path d="M14 13h2" />
|
||||
<path d="M8 17h2" />
|
||||
<path d="M14 17h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple YAML syntax highlighting */
|
||||
function YamlLine({ line }: { line: string }) {
|
||||
// Comment
|
||||
|
||||
276
apps/web/app/components/workspace/html-viewer.tsx
Normal file
276
apps/web/app/components/workspace/html-viewer.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
|
||||
type HtmlViewerProps = {
|
||||
filename: string;
|
||||
/** Raw URL for iframe rendering (served with text/html) */
|
||||
rawUrl: string;
|
||||
/** JSON API URL to fetch source content on demand (for code view) */
|
||||
contentUrl: string;
|
||||
};
|
||||
|
||||
type ViewMode = "rendered" | "code";
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: ["html"],
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
export function HtmlViewer({ filename, rawUrl, contentUrl }: HtmlViewerProps) {
|
||||
const [mode, setMode] = useState<ViewMode>("rendered");
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [sourceLoading, setSourceLoading] = useState(false);
|
||||
|
||||
const handleCodeToggle = useCallback(() => {
|
||||
setMode("code");
|
||||
if (source !== null) {return;}
|
||||
setSourceLoading(true);
|
||||
void fetch(contentUrl)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content: string }) => setSource(data.content))
|
||||
.catch(() => setSource("<!-- Failed to load source -->"))
|
||||
.finally(() => setSourceLoading(false));
|
||||
}, [contentUrl, source]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<HtmlIcon />
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "#f9731618",
|
||||
color: "#f97316",
|
||||
border: "1px solid #f9731630",
|
||||
}}
|
||||
>
|
||||
HTML
|
||||
</span>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div
|
||||
className="flex items-center ml-auto rounded-lg p-0.5"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("rendered")}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "rendered" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "rendered" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "rendered" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<EyeIcon />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodeToggle}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: mode === "code" ? "var(--color-surface)" : "transparent",
|
||||
color: mode === "code" ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
boxShadow: mode === "code" ? "0 1px 2px rgba(0,0,0,0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={rawUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md transition-colors duration-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open in new tab"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mode === "rendered" ? (
|
||||
<RenderedView rawUrl={rawUrl} />
|
||||
) : sourceLoading || source === null ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CodeView content={source} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rendered HTML view (sandboxed iframe) ---
|
||||
|
||||
function RenderedView({ rawUrl }: { rawUrl: string }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden" style={{ background: "white" }}>
|
||||
<iframe
|
||||
src={rawUrl}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups"
|
||||
title="HTML preview"
|
||||
style={{ minHeight: "calc(100vh - 120px)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Syntax-highlighted code view ---
|
||||
|
||||
function CodeView({ content }: { content: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const lineCount = useMemo(() => content.split("\n").length, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getHighlighter().then((highlighter) => {
|
||||
if (cancelled) {return;}
|
||||
const result = highlighter.codeToHtml(content, {
|
||||
lang: "html",
|
||||
themes: { dark: "github-dark", light: "github-light" },
|
||||
});
|
||||
setHtml(result);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto" style={{ background: "var(--color-surface)" }}>
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-t-lg border border-b-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
HTML
|
||||
</span>
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{lineCount} lines
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="code-viewer-content rounded-b-lg border overflow-x-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="code-viewer-highlighted"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm leading-6" style={{ margin: 0 }}>
|
||||
<code>
|
||||
{content.split("\n").map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex hover:bg-[var(--color-surface-hover)] transition-colors duration-75"
|
||||
>
|
||||
<span
|
||||
className="select-none text-right pr-4 pl-4 flex-shrink-0 tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
minWidth: "3rem",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="pr-4 flex-1" style={{ color: "var(--color-text)" }}>
|
||||
{line || " "}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function HtmlIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="12" x2="10" y1="2" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ export type TreeNode = {
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -30,6 +32,9 @@ export function useWorkspaceWatcher() {
|
||||
const [openclawDir, setOpenclawDir] = useState<string | null>(null);
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(null);
|
||||
|
||||
// Show hidden (dot) files/folders
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const retryDelayRef = useRef(1000);
|
||||
// Version counter: prevents stale fetch responses from overwriting newer data.
|
||||
@ -44,7 +49,8 @@ export function useWorkspaceWatcher() {
|
||||
const fetchWorkspaceTree = useCallback(async () => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
const res = await fetch("/api/workspace/tree");
|
||||
const qs = showHidden ? "?showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/tree${qs}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.tree ?? []);
|
||||
@ -57,14 +63,15 @@ export function useWorkspaceWatcher() {
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, []);
|
||||
}, [showHidden]);
|
||||
|
||||
// Fetch a directory listing from the browse API
|
||||
const fetchBrowseTree = useCallback(async (dir: string) => {
|
||||
const version = ++fetchVersionRef.current;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
|
||||
const hiddenQs = showHidden ? "&showHidden=1" : "";
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {
|
||||
setTree(data.entries ?? []);
|
||||
@ -75,7 +82,7 @@ export function useWorkspaceWatcher() {
|
||||
} catch {
|
||||
if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);}
|
||||
}
|
||||
}, []);
|
||||
}, [showHidden]);
|
||||
|
||||
// Smart setBrowseDir: auto-return to workspace mode when navigating to the
|
||||
// workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB
|
||||
@ -211,5 +218,5 @@ export function useWorkspaceWatcher() {
|
||||
};
|
||||
}, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]);
|
||||
|
||||
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile };
|
||||
return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden };
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
|
||||
import { ObjectTable } from "../components/workspace/object-table";
|
||||
import { ObjectKanban } from "../components/workspace/object-kanban";
|
||||
import { DocumentView } from "../components/workspace/document-view";
|
||||
import { FileViewer } from "../components/workspace/file-viewer";
|
||||
import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer";
|
||||
import { HtmlViewer } from "../components/workspace/html-viewer";
|
||||
import { CodeViewer } from "../components/workspace/code-viewer";
|
||||
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
|
||||
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
|
||||
@ -97,6 +98,8 @@ type ContentState =
|
||||
| { kind: "file"; data: FileData; filename: string }
|
||||
| { kind: "code"; data: FileData; filename: string }
|
||||
| { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string }
|
||||
| { kind: "spreadsheet"; url: string; filename: string }
|
||||
| { kind: "html"; rawUrl: string; contentUrl: string; filename: string }
|
||||
| { kind: "database"; dbPath: string; filename: string }
|
||||
| { kind: "report"; reportPath: string; filename: string }
|
||||
| { kind: "directory"; node: TreeNode }
|
||||
@ -656,6 +659,20 @@ function WorkspacePageInner() {
|
||||
} else if (node.type === "report") {
|
||||
setContent({ kind: "report", reportPath: node.path, filename: node.name });
|
||||
} else if (node.type === "file") {
|
||||
// Spreadsheet files get their own binary viewer
|
||||
if (isSpreadsheetFile(node.name)) {
|
||||
const url = rawFileUrl(node.path);
|
||||
setContent({ kind: "spreadsheet", url, filename: node.name });
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML files get an iframe preview
|
||||
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (ext === "html" || ext === "htm") {
|
||||
setContent({ kind: "html", rawUrl: rawFileUrl(node.path), contentUrl: fileApiUrl(node.path), filename: node.name });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a media file (image/video/audio/pdf)
|
||||
const mediaType = detectMediaType(node.name);
|
||||
if (mediaType) {
|
||||
@ -1046,12 +1063,12 @@ function WorkspacePageInner() {
|
||||
params.set("path", activePath);
|
||||
const entry = current.get("entry");
|
||||
if (entry) {params.set("entry", entry);}
|
||||
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
||||
router.push(`/workspace?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
} else if (activeSessionId) {
|
||||
// Chat mode — no file selected.
|
||||
if (current.get("chat") !== activeSessionId || current.has("path")) {
|
||||
router.replace(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
|
||||
router.push(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop
|
||||
@ -1063,7 +1080,7 @@ function WorkspacePageInner() {
|
||||
setEntryModal({ objectName, entryId });
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("entry", `${objectName}:${entryId}`);
|
||||
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
||||
router.push(`/workspace?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[searchParams, router],
|
||||
);
|
||||
@ -2084,6 +2101,24 @@ function ContentRenderer({
|
||||
/>
|
||||
);
|
||||
|
||||
case "spreadsheet":
|
||||
return (
|
||||
<FileViewer
|
||||
filename={content.filename}
|
||||
type="spreadsheet"
|
||||
url={content.url}
|
||||
/>
|
||||
);
|
||||
|
||||
case "html":
|
||||
return (
|
||||
<HtmlViewer
|
||||
rawUrl={content.rawUrl}
|
||||
contentUrl={content.contentUrl}
|
||||
filename={content.filename}
|
||||
/>
|
||||
);
|
||||
|
||||
case "database":
|
||||
return (
|
||||
<DatabaseViewer
|
||||
|
||||
@ -384,12 +384,35 @@ function updateIndex(
|
||||
) {
|
||||
try {
|
||||
const idxPath = indexFile();
|
||||
if (!existsSync(idxPath)) {return;}
|
||||
const index = JSON.parse(
|
||||
let index: Array<Record<string, unknown>>;
|
||||
if (!existsSync(idxPath)) {
|
||||
// Auto-create index with a bootstrap entry for this session so
|
||||
// orphaned .jsonl files become visible in the sidebar.
|
||||
index = [{
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: opts.incrementCount || 0,
|
||||
}];
|
||||
writeFileSync(idxPath, JSON.stringify(index, null, 2));
|
||||
return;
|
||||
}
|
||||
index = JSON.parse(
|
||||
readFileSync(idxPath, "utf-8"),
|
||||
) as Array<Record<string, unknown>>;
|
||||
const session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {return;}
|
||||
let session = index.find((s) => s.id === sessionId);
|
||||
if (!session) {
|
||||
// Session file exists but wasn't indexed — add it.
|
||||
session = {
|
||||
id: sessionId,
|
||||
title: opts.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0,
|
||||
};
|
||||
index.unshift(session);
|
||||
}
|
||||
session.updatedAt = Date.now();
|
||||
if (opts.incrementCount) {
|
||||
session.messageCount =
|
||||
|
||||
@ -19,7 +19,11 @@ const UI_STATE_FILENAME = ".ironclaw-ui-state.json";
|
||||
/** In-memory override; takes precedence over the persisted file. */
|
||||
let _uiActiveProfile: string | null | undefined;
|
||||
|
||||
type UIState = { activeProfile?: string | null };
|
||||
type UIState = {
|
||||
activeProfile?: string | null;
|
||||
/** Maps profile names to absolute workspace paths for workspaces outside ~/.openclaw/. */
|
||||
workspaceRegistry?: Record<string, string>;
|
||||
};
|
||||
|
||||
function uiStatePath(): string {
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
@ -55,7 +59,8 @@ export function getEffectiveProfile(): string | null {
|
||||
export function setUIActiveProfile(profile: string | null): void {
|
||||
const normalized = profile?.trim() || null;
|
||||
_uiActiveProfile = normalized;
|
||||
writeUIState({ activeProfile: normalized });
|
||||
const existing = readUIState();
|
||||
writeUIState({ ...existing, activeProfile: normalized });
|
||||
}
|
||||
|
||||
/** Reset the in-memory override (re-reads from file on next call). */
|
||||
@ -63,6 +68,29 @@ export function clearUIActiveProfileCache(): void {
|
||||
_uiActiveProfile = undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace registry — remembers workspaces created outside ~/.openclaw/.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read the full workspace registry (profile → absolute path). */
|
||||
export function getWorkspaceRegistry(): Record<string, string> {
|
||||
return readUIState().workspaceRegistry ?? {};
|
||||
}
|
||||
|
||||
/** Look up a single profile's registered workspace path. */
|
||||
export function getRegisteredWorkspacePath(profile: string | null): string | null {
|
||||
if (!profile) {return null;}
|
||||
return getWorkspaceRegistry()[profile] ?? null;
|
||||
}
|
||||
|
||||
/** Persist a profile → workspace-path mapping in the registry. */
|
||||
export function registerWorkspacePath(profile: string, absolutePath: string): void {
|
||||
const state = readUIState();
|
||||
const registry = state.workspaceRegistry ?? {};
|
||||
registry[profile] = absolutePath;
|
||||
writeUIState({ ...state, workspaceRegistry: registry });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile discovery — scans the filesystem for all profiles/workspaces.
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -123,6 +151,26 @@ export function discoverProfiles(): DiscoveredProfile[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge workspaces registered via custom paths (outside ~/.openclaw/)
|
||||
const registry = getWorkspaceRegistry();
|
||||
for (const [profileName, wsPath] of Object.entries(registry)) {
|
||||
if (seen.has(profileName)) {
|
||||
const existing = profiles.find((p) => p.name === profileName);
|
||||
if (existing && !existing.workspaceDir && existsSync(wsPath)) {
|
||||
existing.workspaceDir = wsPath;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(profileName);
|
||||
profiles.push({
|
||||
name: profileName,
|
||||
stateDir: baseStateDir,
|
||||
workspaceDir: existsSync(wsPath) ? wsPath : null,
|
||||
isActive: activeProfile === profileName,
|
||||
hasConfig: existsSync(join(baseStateDir, "openclaw.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@ -173,8 +221,10 @@ export function resolveWebChatDir(): string {
|
||||
export function resolveWorkspaceRoot(): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const registryPath = getRegisteredWorkspacePath(profile);
|
||||
const candidates = [
|
||||
process.env.OPENCLAW_WORKSPACE,
|
||||
registryPath,
|
||||
profile && profile.toLowerCase() !== "default"
|
||||
? join(stateDir, `workspace-${profile}`)
|
||||
: null,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Produce a self-contained standalone build so npm global installs
|
||||
@ -17,6 +18,23 @@ const nextConfig: NextConfig = {
|
||||
|
||||
// Transpile ESM-only packages so webpack can bundle them
|
||||
transpilePackages: ["react-markdown", "remark-gfm"],
|
||||
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
ignored: [
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
path.join(homedir(), ".openclaw", "**"),
|
||||
],
|
||||
poll: 1500,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -47,7 +47,8 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.22.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unicode-animations": "^1.0.3"
|
||||
"unicode-animations": "^1.0.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
|
||||
72
pnpm-lock.yaml
generated
72
pnpm-lock.yaml
generated
@ -407,6 +407,9 @@ importers:
|
||||
unicode-animations:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.8
|
||||
@ -3955,6 +3958,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@ -4182,6 +4189,10 @@ packages:
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4260,6 +4271,10 @@ packages:
|
||||
engines: {node: '>= 14.15.0'}
|
||||
hasBin: true
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -4326,6 +4341,11 @@ packages:
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
@ -4753,6 +4773,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
framer-motion@12.34.0:
|
||||
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
|
||||
peerDependencies:
|
||||
@ -6588,6 +6612,10 @@ packages:
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
sshpk@1.18.0:
|
||||
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -7097,6 +7125,14 @@ packages:
|
||||
win-guid@0.2.1:
|
||||
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wordwrapjs@5.1.1:
|
||||
resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==}
|
||||
engines: {node: '>=12.17'}
|
||||
@ -7124,6 +7160,11 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -10792,6 +10833,8 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ai@6.0.86(zod@4.3.6):
|
||||
@ -11027,6 +11070,11 @@ snapshots:
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk-template@0.4.0:
|
||||
@ -11108,6 +11156,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -11160,6 +11210,8 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
croner@10.0.1: {}
|
||||
@ -11600,6 +11652,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.0
|
||||
@ -13999,6 +14053,10 @@ snapshots:
|
||||
sqlite-vec-linux-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-windows-x64: 0.1.7-alpha.2
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
sshpk@1.18.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
@ -14523,6 +14581,10 @@ snapshots:
|
||||
|
||||
win-guid@0.2.1: {}
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wordwrapjs@5.1.1: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
@ -14541,6 +14603,16 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user