feat(web): add workspace app tabs and embedded runtime
This makes Dench apps behave like first-class workspace views with persistent tabs and embedded app loading instead of exposing raw folders.
This commit is contained in:
parent
4e6ce90f0b
commit
8deeccf646
154
apps/web/app/api/apps/route.ts
Normal file
154
apps/web/app/api/apps/route.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { access, readFile, stat } from "node:fs/promises";
|
||||
import { join, extname, resolve, relative } from "node:path";
|
||||
import { resolveWorkspaceRoot, parseSimpleYaml } from "@/lib/workspace";
|
||||
import { injectBridgeIntoHtml } from "@/lib/app-bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".wasm": "application/wasm",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".xml": "application/xml",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
};
|
||||
|
||||
function getMimeType(filepath: string): string {
|
||||
const ext = extname(filepath).toLowerCase();
|
||||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const appPath = url.searchParams.get("app");
|
||||
const filePath = url.searchParams.get("file");
|
||||
const metaOnly = url.searchParams.get("meta") === "1";
|
||||
|
||||
if (!appPath) {
|
||||
return Response.json({ error: "Missing 'app' parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
return Response.json({ error: "No workspace configured" }, { status: 404 });
|
||||
}
|
||||
|
||||
const appAbsPath = resolve(join(workspaceRoot, appPath));
|
||||
|
||||
// Security: ensure the resolved path is within the workspace
|
||||
const relToWorkspace = relative(workspaceRoot, appAbsPath);
|
||||
if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) {
|
||||
return Response.json({ error: "Path traversal denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!await pathExists(appAbsPath)) {
|
||||
return Response.json({ error: "App not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Meta-only request: return parsed .dench.yaml manifest
|
||||
if (metaOnly) {
|
||||
const manifestPath = join(appAbsPath, ".dench.yaml");
|
||||
if (!await pathExists(manifestPath)) {
|
||||
return Response.json({ name: appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App" });
|
||||
}
|
||||
try {
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
return Response.json({
|
||||
name: parsed.name || appPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App",
|
||||
description: parsed.description,
|
||||
icon: parsed.icon,
|
||||
version: parsed.version,
|
||||
author: parsed.author,
|
||||
entry: parsed.entry || "index.html",
|
||||
runtime: parsed.runtime || "static",
|
||||
permissions: parsed.permissions,
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ name: "App" });
|
||||
}
|
||||
}
|
||||
|
||||
// Serve a specific file from the app directory
|
||||
if (!filePath) {
|
||||
return Response.json({ error: "Missing 'file' parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const fileAbsPath = resolve(join(appAbsPath, filePath));
|
||||
|
||||
// Security: ensure file is within the app directory
|
||||
const relToApp = relative(appAbsPath, fileAbsPath);
|
||||
if (relToApp.startsWith("..") || relToApp.startsWith("/")) {
|
||||
return Response.json({ error: "Path traversal denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!await pathExists(fileAbsPath)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(fileAbsPath);
|
||||
if (!fileStat.isFile()) {
|
||||
return Response.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(filePath);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
// For HTML files, inject the DenchClaw bridge SDK
|
||||
if (ext === ".html" || ext === ".htm") {
|
||||
const htmlContent = await readFile(fileAbsPath, "utf-8");
|
||||
const injected = injectBridgeIntoHtml(htmlContent);
|
||||
return new Response(injected, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const content = await readFile(fileAbsPath);
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Content-Length": String(content.length),
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
148
apps/web/app/api/apps/serve/[...path]/route.ts
Normal file
148
apps/web/app/api/apps/serve/[...path]/route.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Path-based app file server.
|
||||
*
|
||||
* Serves files from .dench.app folders via path-based URLs so that relative
|
||||
* references (CSS, JS, images) in HTML files resolve correctly.
|
||||
*
|
||||
* URL format: /api/apps/serve/<appPath>/<filePath>
|
||||
* Example: /api/apps/serve/apps/pacman.dench.app/style.css
|
||||
*
|
||||
* The app path is everything up to and including ".dench.app".
|
||||
* The file path is everything after that.
|
||||
*/
|
||||
import { access, readFile, stat } from "node:fs/promises";
|
||||
import { join, extname, resolve, relative } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
import { injectBridgeIntoHtml } from "@/lib/app-bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".wasm": "application/wasm",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".xml": "application/xml",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
};
|
||||
|
||||
function getMimeType(filepath: string): string {
|
||||
const ext = extname(filepath).toLowerCase();
|
||||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a URL path into app path and file path.
|
||||
* The app path is everything up to and including ".dench.app".
|
||||
* e.g. "apps/pacman.dench.app/style.css" -> ["apps/pacman.dench.app", "style.css"]
|
||||
*/
|
||||
function splitAppPath(segments: string[]): { appPath: string; filePath: string } | null {
|
||||
const joined = segments.join("/");
|
||||
const marker = ".dench.app";
|
||||
const idx = joined.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const appEnd = idx + marker.length;
|
||||
const appPath = joined.slice(0, appEnd);
|
||||
const filePath = joined.slice(appEnd + 1) || "index.html";
|
||||
return { appPath, filePath };
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path: segments } = await params;
|
||||
|
||||
const split = splitAppPath(segments);
|
||||
if (!split) {
|
||||
return Response.json({ error: "Invalid app path — must contain .dench.app" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { appPath, filePath } = split;
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
return Response.json({ error: "No workspace configured" }, { status: 404 });
|
||||
}
|
||||
|
||||
const appAbsPath = resolve(join(workspaceRoot, appPath));
|
||||
const relToWorkspace = relative(workspaceRoot, appAbsPath);
|
||||
if (relToWorkspace.startsWith("..") || relToWorkspace.startsWith("/")) {
|
||||
return Response.json({ error: "Path traversal denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const fileAbsPath = resolve(join(appAbsPath, filePath));
|
||||
const relToApp = relative(appAbsPath, fileAbsPath);
|
||||
if (relToApp.startsWith("..") || relToApp.startsWith("/")) {
|
||||
return Response.json({ error: "Path traversal denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!await pathExists(fileAbsPath)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(fileAbsPath);
|
||||
if (!fileStat.isFile()) {
|
||||
return Response.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(filePath);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
if (ext === ".html" || ext === ".htm") {
|
||||
const htmlContent = await readFile(fileAbsPath, "utf-8");
|
||||
const injected = injectBridgeIntoHtml(htmlContent);
|
||||
return new Response(injected, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const content = await readFile(fileAbsPath);
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Content-Length": String(content.length),
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ export const runtime = "nodejs";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to workspace root (or ~skills/ for virtual nodes)
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -24,6 +24,15 @@ export type TreeNode = {
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
/** App manifest metadata (only for type: "app"). */
|
||||
appManifest?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
entry?: string;
|
||||
runtime?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
@ -97,6 +106,29 @@ async function resolveEntryType(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read .dench.yaml manifest from a .dench.app directory. */
|
||||
async function readAppManifest(
|
||||
dirPath: string,
|
||||
): Promise<TreeNode["appManifest"] | null> {
|
||||
const yamlPath = join(dirPath, ".dench.yaml");
|
||||
if (!await pathExists(yamlPath)) return null;
|
||||
|
||||
try {
|
||||
const content = await readFile(yamlPath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
return {
|
||||
name: (parsed.name as string) || dirPath.split("/").pop()?.replace(/\.dench\.app$/, "") || "App",
|
||||
description: parsed.description as string | undefined,
|
||||
icon: parsed.icon as string | undefined,
|
||||
version: parsed.version as string | undefined,
|
||||
entry: (parsed.entry as string) || "index.html",
|
||||
runtime: (parsed.runtime as string) || "static",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively build a tree from a workspace directory. */
|
||||
async function buildTree(
|
||||
absDir: string,
|
||||
@ -145,6 +177,21 @@ async function buildTree(
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
// Detect .dench.app folders -- treat as app nodes (no children exposed)
|
||||
if (entry.name.endsWith(".dench.app")) {
|
||||
const manifest = await readAppManifest(absPath);
|
||||
const displayName = manifest?.name || entry.name.replace(/\.dench\.app$/, "");
|
||||
nodes.push({
|
||||
name: displayName,
|
||||
path: relPath,
|
||||
type: "app",
|
||||
icon: manifest?.icon,
|
||||
appManifest: manifest ?? { name: displayName, entry: "index.html", runtime: "static" },
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectMeta = await readObjectMeta(absPath);
|
||||
const dbObject = dbObjects.get(entry.name);
|
||||
const children = await buildTree(absPath, relPath, dbObjects, showHidden);
|
||||
|
||||
@ -28,7 +28,7 @@ type MemoryFile = {
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
|
||||
295
apps/web/app/components/workspace/app-viewer.tsx
Normal file
295
apps/web/app/components/workspace/app-viewer.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import type { DenchAppManifest } from "../../workspace/workspace-content";
|
||||
|
||||
/** Build a path-based URL for serving files from a .dench.app folder. */
|
||||
export function appServeUrl(appPath: string, filePath: string): string {
|
||||
return `/api/apps/serve/${appPath}/${filePath}`;
|
||||
}
|
||||
|
||||
type AppViewerProps = {
|
||||
appPath: string;
|
||||
manifest: DenchAppManifest;
|
||||
};
|
||||
|
||||
export function AppViewer({ appPath, manifest }: AppViewerProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const entryFile = manifest.entry || "index.html";
|
||||
const appUrl = appServeUrl(appPath, entryFile);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = `${appUrl}?_t=${Date.now()}`;
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleIframeError = useCallback(() => {
|
||||
setLoading(false);
|
||||
setError("Failed to load app");
|
||||
}, []);
|
||||
|
||||
// Set up postMessage bridge listener
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (!event.data || event.data.type !== "dench:request") return;
|
||||
|
||||
const { id, method, params } = event.data;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return;
|
||||
|
||||
const permissions = manifest.permissions || [];
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
|
||||
if (method === "app.getManifest") {
|
||||
result = manifest;
|
||||
} else if (method === "app.getTheme") {
|
||||
result = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
} else if (method === "db.query" && permissions.includes("database")) {
|
||||
const res = await fetch("/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: params?.sql }),
|
||||
});
|
||||
result = await res.json();
|
||||
} else if (method === "db.execute" && permissions.includes("database")) {
|
||||
const res = await fetch("/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: params?.sql }),
|
||||
});
|
||||
result = await res.json();
|
||||
} else if (method === "files.read" && permissions.includes("files")) {
|
||||
const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(params?.path)}`);
|
||||
result = await res.json();
|
||||
} else if (method === "files.list" && permissions.includes("files")) {
|
||||
const res = await fetch(`/api/workspace/tree?showHidden=0`);
|
||||
result = await res.json();
|
||||
} else {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "dench:response",
|
||||
id,
|
||||
error: `Unknown method or insufficient permissions: ${method}`,
|
||||
}, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "dench:response",
|
||||
id,
|
||||
result,
|
||||
}, "*");
|
||||
} catch (err) {
|
||||
iframe.contentWindow?.postMessage({
|
||||
type: "dench:response",
|
||||
id,
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
}, "*");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [manifest]);
|
||||
|
||||
const iconIsImage = manifest.icon && (
|
||||
manifest.icon.endsWith(".png") ||
|
||||
manifest.icon.endsWith(".svg") ||
|
||||
manifest.icon.endsWith(".jpg") ||
|
||||
manifest.icon.endsWith(".jpeg") ||
|
||||
manifest.icon.endsWith(".webp")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* App header bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-2.5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{iconIsImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={appServeUrl(appPath, manifest.icon!)}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded flex-shrink-0"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<DefaultAppIcon />
|
||||
)}
|
||||
|
||||
<span className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{manifest.name}
|
||||
</span>
|
||||
|
||||
{manifest.version && (
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
v{manifest.version}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "#6366f118",
|
||||
color: "#6366f1",
|
||||
border: "1px solid #6366f130",
|
||||
}}
|
||||
>
|
||||
APP
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
{/* Reload button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReload}
|
||||
className="p-1.5 rounded-md transition-colors duration-100 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Reload app"
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<a
|
||||
href={appUrl}
|
||||
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";
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App content */}
|
||||
<div className="flex-1 overflow-hidden relative" style={{ background: "white" }}>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10" style={{ background: "var(--color-bg)" }}>
|
||||
{iconIsImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={appServeUrl(appPath, manifest.icon!)}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-xl"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center"
|
||||
style={{ background: "var(--color-accent-light)" }}
|
||||
>
|
||||
<DefaultAppIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading {manifest.name}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10" style={{ background: "var(--color-bg)" }}>
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center"
|
||||
style={{ background: "color-mix(in srgb, var(--color-error) 10%, transparent)" }}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReload}
|
||||
className="text-xs px-3 py-1.5 rounded-md cursor-pointer"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={appUrl}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
title={manifest.name}
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
style={{ minHeight: "calc(100vh - 120px)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultAppIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -22,7 +22,7 @@ import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
@ -30,6 +30,15 @@ export type TreeNode = {
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link / shortcut. */
|
||||
symlink?: boolean;
|
||||
/** App manifest metadata (only for type: "app"). */
|
||||
appManifest?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
entry?: string;
|
||||
runtime?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
|
||||
@ -147,6 +156,15 @@ function ChatBubbleIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppNodeIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockBadge() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
||||
@ -199,6 +217,23 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
||||
return <DatabaseIcon />;
|
||||
case "report":
|
||||
return <ReportIcon />;
|
||||
case "app": {
|
||||
const icon = node.appManifest?.icon ?? node.icon;
|
||||
if (icon && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/apps/serve/${node.path}/${icon}`}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-sm flex-shrink-0"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AppNodeIcon />;
|
||||
}
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
@ -210,6 +245,7 @@ function typeColor(node: TreeNode): string {
|
||||
case "document": return "#60a5fa";
|
||||
case "database": return "#c084fc";
|
||||
case "report": return "#22c55e";
|
||||
case "app": return "#6366f1";
|
||||
default: return "var(--color-text-muted)";
|
||||
}
|
||||
}
|
||||
@ -416,7 +452,7 @@ function DraggableNode({
|
||||
// Workspace root in browse mode: non-expandable entry point back to workspace
|
||||
const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot;
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object");
|
||||
const isExpandable = isWorkspaceRoot ? false : node.type === "app" ? false : (hasChildren || node.type === "folder" || node.type === "object");
|
||||
const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path);
|
||||
const isActive = activePath === node.path;
|
||||
const isSelected = selectedPath === node.path;
|
||||
@ -566,6 +602,14 @@ function DraggableNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* App badge */}
|
||||
{node.type === "app" && (
|
||||
<span className="text-[9px] px-1.5 py-[1px] rounded flex-shrink-0 font-medium"
|
||||
style={{ background: "#6366f118", color: "#6366f1" }}>
|
||||
APP
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */}
|
||||
{isProtected && !isWorkspaceRoot && !compact && (
|
||||
<span className="flex-shrink-0 ml-1">
|
||||
|
||||
@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
|
||||
@ -26,7 +26,7 @@ const fileMentionPluginKey = new PluginKey("fileMention");
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
339
apps/web/app/components/workspace/tab-bar.tsx
Normal file
339
apps/web/app/components/workspace/tab-bar.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { type Tab, HOME_TAB_ID } from "@/lib/tab-state";
|
||||
import { appServeUrl } from "./app-viewer";
|
||||
|
||||
type TabBarProps = {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
onActivate: (tabId: string) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
onCloseOthers: (tabId: string) => void;
|
||||
onCloseToRight: (tabId: string) => void;
|
||||
onCloseAll: () => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
onTogglePin: (tabId: string) => void;
|
||||
};
|
||||
|
||||
type ContextMenuState = {
|
||||
tabId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
onReorder,
|
||||
onTogglePin,
|
||||
}: TabBarProps) {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("contextmenu", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("contextmenu", close);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleMiddleClick = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
onClose(tabId);
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||
onReorder(dragIndex, toIndex);
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, [dragIndex, onReorder]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
const contextTab = contextMenu ? tabs.find((t) => t.id === contextMenu.tabId) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex items-end overflow-x-auto flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDragOver = dragOverIndex === index && dragIndex !== index;
|
||||
const isHome = tab.id === HOME_TAB_ID;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
draggable={!isHome}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onMouseDown={isHome ? undefined : (e) => handleMiddleClick(e, tab.id)}
|
||||
onContextMenu={isHome ? undefined : (e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={isHome ? undefined : (e) => handleDragStart(e, index)}
|
||||
onDragOver={isHome ? undefined : (e) => handleDragOver(e, index)}
|
||||
onDrop={isHome ? undefined : (e) => handleDrop(e, index)}
|
||||
onDragEnd={isHome ? undefined : handleDragEnd}
|
||||
className={`group flex items-center gap-1.5 h-[34px] text-[12.5px] font-medium cursor-pointer flex-shrink-0 relative transition-colors duration-75 select-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"}`}
|
||||
style={{
|
||||
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
background: isActive ? "var(--color-bg)" : "transparent",
|
||||
borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent",
|
||||
borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : "2px solid transparent",
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
maxWidth: isHome ? undefined : 200,
|
||||
borderRight: isHome ? "1px solid var(--color-border)" : undefined,
|
||||
}}
|
||||
title={isHome ? "Home (New Chat)" : undefined}
|
||||
>
|
||||
{isHome ? (
|
||||
<HomeIcon />
|
||||
) : (
|
||||
<>
|
||||
{tab.pinned && <PinIcon />}
|
||||
<TabIcon type={tab.type} icon={tab.icon} appPath={tab.path} />
|
||||
<span className="truncate max-w-[140px]">{tab.title}</span>
|
||||
{!tab.pinned && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => { e.stopPropagation(); onClose(tab.id); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(tab.id); } }}
|
||||
className="ml-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && contextTab && (
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] rounded-lg border py-1 shadow-lg"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<ContextMenuItem
|
||||
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
|
||||
onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }}
|
||||
/>
|
||||
<div className="h-px my-1" style={{ background: "var(--color-border)" }} />
|
||||
<ContextMenuItem
|
||||
label="Close"
|
||||
shortcut="⌘W"
|
||||
disabled={contextTab.pinned}
|
||||
onClick={() => { onClose(contextMenu.tabId); setContextMenu(null); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="Close Others"
|
||||
onClick={() => { onCloseOthers(contextMenu.tabId); setContextMenu(null); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="Close to the Right"
|
||||
onClick={() => { onCloseToRight(contextMenu.tabId); setContextMenu(null); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="Close All"
|
||||
onClick={() => { onCloseAll(); setContextMenu(null); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
label,
|
||||
shortcut,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[12.5px] text-left transition-colors disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{shortcut && (
|
||||
<span className="ml-4 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabIcon({ type, icon, appPath }: { type: string; icon?: string; appPath?: string }) {
|
||||
if (icon && appPath && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={appServeUrl(appPath, icon)}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="rounded-sm flex-shrink-0"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "home":
|
||||
return <HomeIcon />;
|
||||
case "app":
|
||||
return <AppIcon />;
|
||||
case "chat":
|
||||
return <ChatIcon />;
|
||||
case "cron":
|
||||
return <CronIcon />;
|
||||
case "object":
|
||||
return <ObjectIcon />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="flex-shrink-0" style={{ opacity: 0.5 }}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.7 }}>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AppIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CronIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" /><path d="M9 21V9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report" | "app";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
|
||||
@ -17,6 +17,7 @@ import { DocumentView } from "../components/workspace/document-view";
|
||||
import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer";
|
||||
import { SpreadsheetEditor } from "../components/workspace/spreadsheet-editor";
|
||||
import { HtmlViewer } from "../components/workspace/html-viewer";
|
||||
import { AppViewer } from "../components/workspace/app-viewer";
|
||||
import { MonacoCodeEditor } from "../components/workspace/code-editor";
|
||||
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
|
||||
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
|
||||
@ -45,6 +46,15 @@ import {
|
||||
import { UnicodeSpinner } from "../components/unicode-spinner";
|
||||
import { resolveActiveViewSyncDecision } from "./object-view-active-view";
|
||||
import { resetWorkspaceStateOnSwitch } from "./workspace-switch";
|
||||
import { TabBar } from "../components/workspace/tab-bar";
|
||||
import {
|
||||
type Tab, type TabState,
|
||||
HOME_TAB_ID, HOME_TAB,
|
||||
generateTabId, loadTabs, saveTabs, openTab, closeTab,
|
||||
closeOtherTabs, closeTabsToRight, closeAllTabs,
|
||||
activateTab, reorderTabs, togglePinTab,
|
||||
inferTabType, inferTabTitle,
|
||||
} from "@/lib/tab-state";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const TerminalDrawer = dynamic(
|
||||
@ -129,7 +139,19 @@ type ContentState =
|
||||
| { kind: "cron-job"; jobId: string; job: CronJob }
|
||||
| { kind: "cron-session"; jobId: string; job: CronJob; sessionId: string; run: import("../types/cron").CronRunLogEntry }
|
||||
| { kind: "duckdb-missing" }
|
||||
| { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" };
|
||||
| { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" }
|
||||
| { kind: "app"; appPath: string; manifest: DenchAppManifest; filename: string };
|
||||
|
||||
export type DenchAppManifest = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
entry?: string;
|
||||
runtime?: "static" | "esbuild" | "build";
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
type SidebarPreviewContent =
|
||||
| { kind: "document"; data: FileData; title: string }
|
||||
@ -281,6 +303,7 @@ function objectNameFromPath(path: string): string {
|
||||
|
||||
/** Infer a tree node type from filename extension for ad-hoc path previews. */
|
||||
function inferNodeTypeFromFileName(fileName: string): TreeNode["type"] {
|
||||
if (fileName.endsWith(".dench.app")) return "app";
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (ext === "md" || ext === "mdx") {return "document";}
|
||||
if (ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db") {return "database";}
|
||||
@ -485,6 +508,41 @@ function WorkspacePageInner() {
|
||||
// Terminal drawer state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
|
||||
// Tab state -- always starts with the home tab
|
||||
const [tabState, setTabState] = useState<TabState>({ tabs: [HOME_TAB], activeTabId: HOME_TAB_ID });
|
||||
// Track which workspace we loaded tabs for, so we reload if the workspace switches
|
||||
// and don't save until we've loaded first.
|
||||
const tabLoadedForWorkspace = useRef<string | null>(null);
|
||||
|
||||
// Load tabs from localStorage once workspace name is known
|
||||
useEffect(() => {
|
||||
const key = workspaceName || null;
|
||||
if (tabLoadedForWorkspace.current === key) return;
|
||||
tabLoadedForWorkspace.current = key;
|
||||
const loaded = loadTabs(key);
|
||||
setTabState(loaded);
|
||||
}, [workspaceName]);
|
||||
|
||||
// Persist tabs to localStorage on change (only after initial load for this workspace)
|
||||
useEffect(() => {
|
||||
const key = workspaceName || null;
|
||||
if (tabLoadedForWorkspace.current !== key) return;
|
||||
saveTabs(tabState, key);
|
||||
}, [tabState, workspaceName]);
|
||||
|
||||
// Ref for the keyboard shortcut to close the active tab (avoids stale closure over loadContent)
|
||||
const tabCloseActiveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const openTabForNode = useCallback((node: { path: string; name: string; type: string }) => {
|
||||
const tab: Tab = {
|
||||
id: generateTabId(),
|
||||
type: node.type === "object" ? "object" : inferTabType(node.path),
|
||||
title: inferTabTitle(node.path, node.name),
|
||||
path: node.path,
|
||||
};
|
||||
setTabState((prev) => openTab(prev, tab));
|
||||
}, []);
|
||||
|
||||
// Resizable sidebar widths (desktop only; persisted in localStorage).
|
||||
// Use static defaults so server and client match on first render (avoid hydration mismatch).
|
||||
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
|
||||
@ -529,6 +587,12 @@ function WorkspacePageInner() {
|
||||
setTerminalOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mod && key === "w" && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
tabCloseActiveRef.current?.();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
@ -789,6 +853,14 @@ function WorkspacePageInner() {
|
||||
} else {
|
||||
setContent({ kind: "file", data, filename: node.name });
|
||||
}
|
||||
} else if (node.type === "app") {
|
||||
// Fetch manifest from the tree node or API
|
||||
const manifestRes = await fetch(`/api/apps?app=${encodeURIComponent(node.path)}&file=.dench.yaml&meta=1`);
|
||||
let manifest: DenchAppManifest = { name: node.name };
|
||||
if (manifestRes.ok) {
|
||||
try { manifest = await manifestRes.json(); } catch { /* use default */ }
|
||||
}
|
||||
setContent({ kind: "app", appPath: node.path, manifest, filename: node.name });
|
||||
} else if (node.type === "folder") {
|
||||
setContent({ kind: "directory", node });
|
||||
}
|
||||
@ -874,11 +946,96 @@ function WorkspacePageInner() {
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
return;
|
||||
}
|
||||
openTabForNode(node);
|
||||
void loadContent(node);
|
||||
},
|
||||
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
|
||||
[loadContent, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
|
||||
);
|
||||
|
||||
// Tab handler callbacks (defined after loadContent is available)
|
||||
const handleTabActivate = useCallback((tabId: string) => {
|
||||
if (tabId === HOME_TAB_ID) {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setTabState((prev) => activateTab(prev, tabId));
|
||||
return;
|
||||
}
|
||||
setTabState((prev) => {
|
||||
const next = activateTab(prev, tabId);
|
||||
const tab = next.tabs.find((t) => t.id === tabId);
|
||||
if (tab?.path) {
|
||||
const node = resolveNode(tree, tab.path);
|
||||
if (node) {
|
||||
void loadContent(node);
|
||||
} else if (tab.path === "~cron") {
|
||||
setActivePath("~cron");
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
} else if (tab.path.startsWith("~cron/")) {
|
||||
setActivePath(tab.path);
|
||||
const jobId = tab.path.slice("~cron/".length);
|
||||
const job = cronJobs.find((j) => j.id === jobId);
|
||||
if (job) setContent({ kind: "cron-job", jobId, job });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tree, loadContent, cronJobs]);
|
||||
|
||||
const handleTabClose = useCallback((tabId: string) => {
|
||||
setTabState((prev) => {
|
||||
const next = closeTab(prev, tabId);
|
||||
if (next.activeTabId !== prev.activeTabId) {
|
||||
if (next.activeTabId === HOME_TAB_ID || !next.activeTabId) {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
} else {
|
||||
const newActive = next.tabs.find((t) => t.id === next.activeTabId);
|
||||
if (newActive?.path) {
|
||||
const node = resolveNode(tree, newActive.path);
|
||||
if (node) {
|
||||
void loadContent(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tree, loadContent]);
|
||||
|
||||
// Keep ref in sync so keyboard shortcut can close active tab
|
||||
useEffect(() => {
|
||||
tabCloseActiveRef.current = () => {
|
||||
if (tabState.activeTabId) {
|
||||
handleTabClose(tabState.activeTabId);
|
||||
}
|
||||
};
|
||||
}, [tabState.activeTabId, handleTabClose]);
|
||||
|
||||
const handleTabCloseOthers = useCallback((tabId: string) => {
|
||||
setTabState((prev) => closeOtherTabs(prev, tabId));
|
||||
}, []);
|
||||
|
||||
const handleTabCloseToRight = useCallback((tabId: string) => {
|
||||
setTabState((prev) => closeTabsToRight(prev, tabId));
|
||||
}, []);
|
||||
|
||||
const handleTabCloseAll = useCallback(() => {
|
||||
setTabState((prev) => {
|
||||
const next = closeAllTabs(prev);
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTabReorder = useCallback((from: number, to: number) => {
|
||||
setTabState((prev) => reorderTabs(prev, from, to));
|
||||
}, []);
|
||||
|
||||
const handleTabTogglePin = useCallback((tabId: string) => {
|
||||
setTabState((prev) => togglePinTab(prev, tabId));
|
||||
}, []);
|
||||
|
||||
const loadSidebarPreviewFromNode = useCallback(
|
||||
async (node: TreeNode): Promise<SidebarPreviewContent | null> => {
|
||||
if (node.type === "folder") {
|
||||
@ -1137,6 +1294,7 @@ function WorkspacePageInner() {
|
||||
const handleGoToChat = useCallback(() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
|
||||
}, []);
|
||||
|
||||
// Insert a file mention into the chat editor when a sidebar item is dropped on the chat input.
|
||||
@ -1288,14 +1446,17 @@ function WorkspacePageInner() {
|
||||
initialPathHandled.current = true;
|
||||
const node = resolveNode(tree, urlState.path);
|
||||
if (node) {
|
||||
openTabForNode(node);
|
||||
void loadContent(node);
|
||||
} else if (urlState.path === "~cron") {
|
||||
openTabForNode({ path: "~cron", name: "Cron", type: "folder" });
|
||||
setActivePath("~cron");
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
if (urlState.cronView) setCronView(urlState.cronView);
|
||||
if (urlState.cronCalMode) setCronCalMode(urlState.cronCalMode);
|
||||
if (urlState.cronDate) setCronDate(urlState.cronDate);
|
||||
} else if (urlState.path.startsWith("~cron/")) {
|
||||
openTabForNode({ path: urlState.path, name: urlState.path.split("/").pop() || "Cron Job", type: "file" });
|
||||
setActivePath(urlState.path);
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
if (urlState.cronRunFilter) setCronRunFilter(urlState.cronRunFilter);
|
||||
@ -1303,6 +1464,7 @@ function WorkspacePageInner() {
|
||||
} else if (isAbsolutePath(urlState.path) || isHomeRelativePath(urlState.path)) {
|
||||
const name = urlState.path.split("/").pop() || urlState.path;
|
||||
const syntheticNode: TreeNode = { name, path: urlState.path, type: "file" };
|
||||
openTabForNode(syntheticNode);
|
||||
void loadContent(syntheticNode);
|
||||
}
|
||||
if (urlState.fileChat) {
|
||||
@ -1365,11 +1527,14 @@ function WorkspacePageInner() {
|
||||
if (urlState.path) {
|
||||
const node = resolveNode(tree, urlState.path);
|
||||
if (node) {
|
||||
openTabForNode(node);
|
||||
void loadContent(node);
|
||||
} else if (urlState.path === "~cron") {
|
||||
openTabForNode({ path: "~cron", name: "Cron", type: "folder" });
|
||||
setActivePath("~cron");
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
} else if (urlState.path.startsWith("~cron/")) {
|
||||
openTabForNode({ path: urlState.path, name: urlState.path.split("/").pop() || "Cron Job", type: "file" });
|
||||
setActivePath(urlState.path);
|
||||
const jobId = urlState.path.slice("~cron/".length);
|
||||
const job = cronJobs.find((j) => j.id === jobId);
|
||||
@ -1380,7 +1545,9 @@ function WorkspacePageInner() {
|
||||
}
|
||||
} else if (isAbsolutePath(urlState.path) || isHomeRelativePath(urlState.path)) {
|
||||
const name = urlState.path.split("/").pop() || urlState.path;
|
||||
void loadContent({ name, path: urlState.path, type: "file" });
|
||||
const synNode: TreeNode = { name, path: urlState.path, type: "file" };
|
||||
openTabForNode(synNode);
|
||||
void loadContent(synNode);
|
||||
}
|
||||
setFileChatSessionId(urlState.fileChat);
|
||||
} else if (urlState.chat) {
|
||||
@ -1389,11 +1556,13 @@ function WorkspacePageInner() {
|
||||
setContent({ kind: "none" });
|
||||
void chatRef.current?.loadSession(urlState.chat);
|
||||
setActiveSubagentKey(urlState.subagent);
|
||||
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
|
||||
} else {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setActiveSessionId(null);
|
||||
setActiveSubagentKey(null);
|
||||
setTabState((prev) => activateTab(prev, HOME_TAB_ID));
|
||||
}
|
||||
|
||||
if (urlState.entry) {
|
||||
@ -1779,6 +1948,21 @@ function WorkspacePageInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab bar (desktop only, always visible -- home tab is always present) */}
|
||||
{!isMobile && (
|
||||
<TabBar
|
||||
tabs={tabState.tabs}
|
||||
activeTabId={tabState.activeTabId}
|
||||
onActivate={handleTabActivate}
|
||||
onClose={handleTabClose}
|
||||
onCloseOthers={handleTabCloseOthers}
|
||||
onCloseToRight={handleTabCloseToRight}
|
||||
onCloseAll={handleTabCloseAll}
|
||||
onReorder={handleTabReorder}
|
||||
onTogglePin={handleTabTogglePin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
|
||||
{!isMobile && activePath && content.kind !== "none" && (
|
||||
<div
|
||||
@ -2510,6 +2694,14 @@ function ContentRenderer({
|
||||
/>
|
||||
);
|
||||
|
||||
case "app":
|
||||
return (
|
||||
<AppViewer
|
||||
appPath={content.appPath}
|
||||
manifest={content.manifest}
|
||||
/>
|
||||
);
|
||||
|
||||
case "database":
|
||||
return (
|
||||
<DatabaseViewer
|
||||
|
||||
92
apps/web/lib/app-bridge.ts
Normal file
92
apps/web/lib/app-bridge.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* DenchClaw App Bridge SDK.
|
||||
*
|
||||
* This module generates the client-side SDK script that gets injected into
|
||||
* app iframes, providing `window.dench` for app-to-DenchClaw communication.
|
||||
*
|
||||
* Protocol:
|
||||
* App -> Parent: { type: "dench:request", id, method, params }
|
||||
* Parent -> App: { type: "dench:response", id, result, error }
|
||||
*/
|
||||
|
||||
export function generateBridgeScript(): string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.dench) return;
|
||||
|
||||
var _pendingRequests = {};
|
||||
var _requestId = 0;
|
||||
|
||||
function sendRequest(method, params) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var id = ++_requestId;
|
||||
_pendingRequests[id] = { resolve: resolve, reject: reject };
|
||||
window.parent.postMessage({
|
||||
type: "dench:request",
|
||||
id: id,
|
||||
method: method,
|
||||
params: params
|
||||
}, "*");
|
||||
|
||||
setTimeout(function() {
|
||||
if (_pendingRequests[id]) {
|
||||
_pendingRequests[id].reject(new Error("Request timeout: " + method));
|
||||
delete _pendingRequests[id];
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("message", function(event) {
|
||||
if (!event.data || event.data.type !== "dench:response") return;
|
||||
var pending = _pendingRequests[event.data.id];
|
||||
if (!pending) return;
|
||||
delete _pendingRequests[event.data.id];
|
||||
if (event.data.error) {
|
||||
pending.reject(new Error(event.data.error));
|
||||
} else {
|
||||
pending.resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
|
||||
window.dench = {
|
||||
db: {
|
||||
query: function(sql) { return sendRequest("db.query", { sql: sql }); },
|
||||
execute: function(sql) { return sendRequest("db.execute", { sql: sql }); }
|
||||
},
|
||||
files: {
|
||||
read: function(path) { return sendRequest("files.read", { path: path }); },
|
||||
list: function(dir) { return sendRequest("files.list", { dir: dir }); }
|
||||
},
|
||||
app: {
|
||||
getManifest: function() { return sendRequest("app.getManifest"); },
|
||||
getTheme: function() { return sendRequest("app.getTheme"); }
|
||||
},
|
||||
agent: {
|
||||
send: function(message) { return sendRequest("agent.send", { message: message }); }
|
||||
}
|
||||
};
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps raw HTML content with the bridge SDK script tag.
|
||||
* Used when serving HTML files to inject the SDK automatically.
|
||||
*/
|
||||
export function injectBridgeIntoHtml(html: string): string {
|
||||
const script = `<script>${generateBridgeScript()}</script>`;
|
||||
|
||||
// Try to inject before </head>
|
||||
if (html.includes("</head>")) {
|
||||
return html.replace("</head>", `${script}\n</head>`);
|
||||
}
|
||||
|
||||
// Try to inject after <head>
|
||||
if (html.includes("<head>")) {
|
||||
return html.replace("<head>", `<head>\n${script}`);
|
||||
}
|
||||
|
||||
// Fallback: prepend to the HTML
|
||||
return `${script}\n${html}`;
|
||||
}
|
||||
195
apps/web/lib/tab-state.ts
Normal file
195
apps/web/lib/tab-state.ts
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Tab state management for the workspace.
|
||||
*
|
||||
* Tabs are stored in localStorage keyed per workspace.
|
||||
* The URL reflects only the active tab's content (backward compatible).
|
||||
*/
|
||||
|
||||
export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron";
|
||||
|
||||
export const HOME_TAB_ID = "__home__";
|
||||
|
||||
export const HOME_TAB: Tab = {
|
||||
id: HOME_TAB_ID,
|
||||
type: "home",
|
||||
title: "Home",
|
||||
pinned: true,
|
||||
};
|
||||
|
||||
export type Tab = {
|
||||
id: string;
|
||||
type: TabType;
|
||||
title: string;
|
||||
icon?: string;
|
||||
path?: string;
|
||||
sessionId?: string;
|
||||
pinned?: boolean;
|
||||
};
|
||||
|
||||
export type TabState = {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "dench:tabs";
|
||||
|
||||
function storageKey(workspaceId?: string | null): string {
|
||||
return `${STORAGE_PREFIX}:${workspaceId || "default"}`;
|
||||
}
|
||||
|
||||
export function generateTabId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function ensureHomeTab(state: TabState): TabState {
|
||||
const hasHome = state.tabs.some((t) => t.id === HOME_TAB_ID);
|
||||
if (hasHome) {
|
||||
// Make sure home is always first
|
||||
const home = state.tabs.find((t) => t.id === HOME_TAB_ID)!;
|
||||
const rest = state.tabs.filter((t) => t.id !== HOME_TAB_ID);
|
||||
return { ...state, tabs: [home, ...rest] };
|
||||
}
|
||||
return {
|
||||
tabs: [HOME_TAB, ...state.tabs],
|
||||
activeTabId: state.activeTabId || HOME_TAB_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadTabs(workspaceId?: string | null): TabState {
|
||||
if (typeof window === "undefined") return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(workspaceId));
|
||||
if (!raw) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
|
||||
const parsed = JSON.parse(raw) as TabState;
|
||||
if (!Array.isArray(parsed.tabs)) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
|
||||
return ensureHomeTab(parsed);
|
||||
} catch {
|
||||
return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTabs(state: TabState, workspaceId?: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const serializable: TabState = {
|
||||
tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, pinned }) => ({
|
||||
id, type, title, icon, path, sessionId, pinned,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
};
|
||||
localStorage.setItem(storageKey(workspaceId), JSON.stringify(serializable));
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function findTabByPath(tabs: Tab[], path: string): Tab | undefined {
|
||||
return tabs.find((t) => t.path === path);
|
||||
}
|
||||
|
||||
export function findTabBySessionId(tabs: Tab[], sessionId: string): Tab | undefined {
|
||||
return tabs.find((t) => t.type === "chat" && t.sessionId === sessionId);
|
||||
}
|
||||
|
||||
export function openTab(state: TabState, tab: Tab): TabState {
|
||||
const existing = tab.path
|
||||
? findTabByPath(state.tabs, tab.path)
|
||||
: tab.sessionId
|
||||
? findTabBySessionId(state.tabs, tab.sessionId)
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
return { ...state, activeTabId: existing.id };
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeTab(state: TabState, tabId: string): TabState {
|
||||
if (tabId === HOME_TAB_ID) return state;
|
||||
const idx = state.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return state;
|
||||
if (state.tabs[idx].pinned) return state;
|
||||
|
||||
const newTabs = state.tabs.filter((t) => t.id !== tabId);
|
||||
let newActiveId = state.activeTabId;
|
||||
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null;
|
||||
} else if (idx < newTabs.length) {
|
||||
newActiveId = newTabs[idx].id;
|
||||
} else {
|
||||
newActiveId = newTabs[newTabs.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveId };
|
||||
}
|
||||
|
||||
export function closeOtherTabs(state: TabState, tabId: string): TabState {
|
||||
const keep = state.tabs.filter((t) => t.id === tabId || t.pinned);
|
||||
return { tabs: keep, activeTabId: tabId };
|
||||
}
|
||||
|
||||
export function closeTabsToRight(state: TabState, tabId: string): TabState {
|
||||
const idx = state.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return state;
|
||||
const keep = state.tabs.filter((t, i) => i <= idx || t.pinned);
|
||||
const activeStillExists = keep.some((t) => t.id === state.activeTabId);
|
||||
return { tabs: keep, activeTabId: activeStillExists ? state.activeTabId : tabId };
|
||||
}
|
||||
|
||||
export function closeAllTabs(state: TabState): TabState {
|
||||
const pinned = state.tabs.filter((t) => t.pinned);
|
||||
const activeStillExists = pinned.some((t) => t.id === state.activeTabId);
|
||||
return { tabs: pinned, activeTabId: activeStillExists ? state.activeTabId : HOME_TAB_ID };
|
||||
}
|
||||
|
||||
export function activateTab(state: TabState, tabId: string): TabState {
|
||||
if (!state.tabs.some((t) => t.id === tabId)) return state;
|
||||
return { ...state, activeTabId: tabId };
|
||||
}
|
||||
|
||||
export function reorderTabs(state: TabState, fromIndex: number, toIndex: number): TabState {
|
||||
if (fromIndex === toIndex) return state;
|
||||
// Don't allow moving the home tab or moving anything before it
|
||||
if (state.tabs[fromIndex]?.id === HOME_TAB_ID) return state;
|
||||
const effectiveTo = Math.max(1, toIndex); // keep index 0 reserved for home
|
||||
const tabs = [...state.tabs];
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
tabs.splice(effectiveTo, 0, moved);
|
||||
return { ...state, tabs };
|
||||
}
|
||||
|
||||
export function togglePinTab(state: TabState, tabId: string): TabState {
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, pinned: !t.pinned } : t,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTabTitle(state: TabState, tabId: string, title: string): TabState {
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, title } : t,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function inferTabType(path: string): TabType {
|
||||
if (path.includes(".dench.app")) return "app";
|
||||
if (path.startsWith("~cron")) return "cron";
|
||||
return "file";
|
||||
}
|
||||
|
||||
export function inferTabTitle(path: string, name?: string): string {
|
||||
if (name) return name;
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export {
|
||||
buildDenchClawIdentity,
|
||||
seedWorkspaceFromAssets,
|
||||
seedSampleApp,
|
||||
} from "../../../src/cli/workspace-seed";
|
||||
|
||||
@ -91,6 +91,7 @@ export const SEED_OBJECTS: SeedObject[] = [
|
||||
export function buildDenchClawIdentity(workspaceDir: string): string {
|
||||
const crmSkillPath = path.join(workspaceDir, "skills", "crm", "SKILL.md");
|
||||
const browserSkillPath = path.join(workspaceDir, "skills", "browser", "SKILL.md");
|
||||
const appBuilderSkillPath = path.join(workspaceDir, "skills", "app-builder", "SKILL.md");
|
||||
return `# IDENTITY.md - DenchClaw
|
||||
|
||||
You are **DenchClaw** - a personal AI agent and a CRM Database System built by Dench (dench.com), running on top of [OpenClaw](https://github.com/openclaw/openclaw).
|
||||
@ -116,11 +117,22 @@ Your browser automation behavior is defined by the Browser skill at:
|
||||
- Always load and follow that skill for browser-based tasks.
|
||||
- Treat the Browser skill as always-on system context.
|
||||
|
||||
## App Builder contract
|
||||
|
||||
Your app-building behavior is defined by the App Builder skill at:
|
||||
\`${appBuilderSkillPath}\`
|
||||
|
||||
- Always load and follow that skill for app creation tasks.
|
||||
- Treat the App Builder skill as always-on system context.
|
||||
- Build apps using the \`.dench.app\` folder format with \`.dench.yaml\` manifests.
|
||||
- Default app location: \`${workspaceDir}/apps/\`
|
||||
|
||||
## What you do
|
||||
|
||||
- Find and enrich leads, maintain CRM pipelines, and help run outreach workflows.
|
||||
- Chat with local DuckDB workspace data and return structured insights.
|
||||
- Generate analytics and maintain workspace documentation.
|
||||
- Build custom apps that run inside the workspace with access to DuckDB data.
|
||||
|
||||
## Links
|
||||
|
||||
@ -188,6 +200,7 @@ export function seedDenchClawIdentity(workspaceDir: string): void {
|
||||
export const MANAGED_SKILLS: ReadonlyArray<{ name: string; templatePaths?: boolean }> = [
|
||||
{ name: "crm", templatePaths: true },
|
||||
{ name: "browser" },
|
||||
{ name: "app-builder", templatePaths: true },
|
||||
];
|
||||
|
||||
export function seedSkill(
|
||||
@ -279,6 +292,80 @@ export function syncManagedSkills(params: {
|
||||
return { syncedSkills: synced, workspaceDirs: params.workspaceDirs, identityUpdated: true };
|
||||
}
|
||||
|
||||
export function seedSampleApp(appsDir: string): void {
|
||||
const appDir = path.join(appsDir, "hello.dench.app");
|
||||
if (existsSync(appDir)) return;
|
||||
|
||||
mkdirSync(appDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
path.join(appDir, ".dench.yaml"),
|
||||
`name: "Hello World"
|
||||
description: "A sample DenchClaw app"
|
||||
icon: "sparkles"
|
||||
version: "1.0.0"
|
||||
entry: "index.html"
|
||||
runtime: "static"
|
||||
permissions:
|
||||
- database
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
path.join(appDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hello World</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 32px; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
body.dark { background: #1a1a2e; color: #e0e0e0; }
|
||||
body.light { background: #ffffff; color: #1a1a2e; }
|
||||
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
p { opacity: 0.6; margin-bottom: 24px; }
|
||||
.stats { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
|
||||
.stat { padding: 16px 24px; border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); border: 1px solid color-mix(in srgb, currentColor 10%, transparent); min-width: 120px; }
|
||||
.stat .label { font-size: 12px; opacity: 0.5; margin-bottom: 4px; }
|
||||
.stat .value { font-size: 24px; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello from DenchClaw!</h1>
|
||||
<p>This is a sample app running inside your workspace.</p>
|
||||
<div class="stats" id="stats">Loading...</div>
|
||||
<script>
|
||||
async function init() {
|
||||
try {
|
||||
const theme = await window.dench.app.getTheme();
|
||||
document.body.className = theme;
|
||||
} catch { document.body.className = 'light'; }
|
||||
try {
|
||||
const result = await window.dench.db.query("SELECT name, entry_count FROM objects");
|
||||
const el = document.getElementById('stats');
|
||||
el.innerHTML = '';
|
||||
for (const row of (result.rows || [])) {
|
||||
el.innerHTML += '<div class="stat"><div class="label">' + row.name + '</div><div class="value">' + (row.entry_count || 0) + '</div></div>';
|
||||
}
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
el.textContent = 'No objects found yet. Create some in DenchClaw!';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('stats').textContent = 'Could not load data: ' + err.message;
|
||||
}
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
export function writeIfMissing(filePath: string, content: string): boolean {
|
||||
if (existsSync(filePath)) {
|
||||
return false;
|
||||
@ -352,6 +439,13 @@ export function seedWorkspaceFromAssets(params: {
|
||||
}
|
||||
writeIfMissing(path.join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
|
||||
|
||||
// Create default apps directory
|
||||
const appsDir = path.join(workspaceDir, "apps");
|
||||
mkdirSync(appsDir, { recursive: true });
|
||||
|
||||
// Seed a sample hello-world app
|
||||
seedSampleApp(appsDir);
|
||||
|
||||
return {
|
||||
workspaceDir,
|
||||
dbPath,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user