📦 NEW: workspace sidebar
This commit is contained in:
parent
ac558d0a8e
commit
f78cbbf563
96
apps/web/app/api/workspace/browse-file/route.ts
Normal file
96
apps/web/app/api/workspace/browse-file/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** MIME types for common file extensions. */
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const filePath = url.searchParams.get("path");
|
||||
const raw = url.searchParams.get("raw") === "true";
|
||||
|
||||
if (!filePath) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize and resolve to prevent traversal
|
||||
const resolved = resolve(normalize(filePath));
|
||||
|
||||
if (!existsSync(resolved)) {
|
||||
return Response.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolved);
|
||||
if (!stat.isFile()) {
|
||||
return Response.json(
|
||||
{ error: "Path is not a file" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot stat file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Raw mode: return binary content with appropriate MIME type
|
||||
if (raw) {
|
||||
try {
|
||||
const buffer = readFileSync(resolved);
|
||||
const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mime = MIME_MAP[ext] ?? "application/octet-stream";
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot read file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode: return content and type metadata (same shape as /api/workspace/file)
|
||||
try {
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const ext = resolved.split(".").pop()?.toLowerCase();
|
||||
|
||||
let type: "markdown" | "yaml" | "text" = "text";
|
||||
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
||||
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
||||
|
||||
return Response.json({ content, type });
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot read file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
97
apps/web/app/api/workspace/browse/route.ts
Normal file
97
apps/web/app/api/workspace/browse/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { readdirSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { resolveDenchRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type BrowseNode = {
|
||||
name: string;
|
||||
path: string; // absolute path
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
children?: BrowseNode[];
|
||||
};
|
||||
|
||||
/** Directories to skip when browsing the filesystem. */
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
||||
|
||||
/** Build a depth-limited tree from an absolute directory. */
|
||||
function buildBrowseTree(
|
||||
absDir: string,
|
||||
maxDepth: number,
|
||||
currentDepth = 0,
|
||||
): BrowseNode[] {
|
||||
if (currentDepth >= maxDepth) {return [];}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
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 nodes: BrowseNode[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
const absPath = join(absDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
});
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
let dir = url.searchParams.get("dir");
|
||||
|
||||
// Default to the dench workspace root
|
||||
if (!dir) {
|
||||
dir = resolveDenchRoot();
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
return Response.json(
|
||||
{ entries: [], currentDir: "/", parentDir: null },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve and normalize the directory path
|
||||
const resolved = resolve(dir);
|
||||
|
||||
const entries = buildBrowseTree(resolved, 3);
|
||||
const parentDir = resolved === "/" ? null : dirname(resolved);
|
||||
|
||||
return Response.json({
|
||||
entries,
|
||||
currentDir: resolved,
|
||||
parentDir,
|
||||
});
|
||||
}
|
||||
@ -311,7 +311,7 @@ export async function GET() {
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
return Response.json({ tree, exists: false });
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
@ -332,5 +332,5 @@ export async function GET() {
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
|
||||
return Response.json({ tree, exists: true });
|
||||
return Response.json({ tree, exists: true, workspaceRoot: root });
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { CronJob, CronRunLogEntry, CronRunsResponse } from "../../types/cron";
|
||||
import { CronRunChat } from "./cron-run-chat";
|
||||
|
||||
@ -377,21 +379,31 @@ function RunCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{run.summary && (
|
||||
<div className="mt-3 text-sm" style={{ color: "var(--color-text)" }}>
|
||||
{run.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session transcript */}
|
||||
{/* Session transcript (full chat) or summary fallback */}
|
||||
{run.sessionId ? (
|
||||
<div className="mt-4">
|
||||
<CronRunChat sessionId={run.sessionId} />
|
||||
</div>
|
||||
) : run.summary ? (
|
||||
<div className="mt-3">
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-wider font-medium mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Run Output
|
||||
</div>
|
||||
<div
|
||||
className="chat-prose text-sm"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{run.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No session transcript available for this run.
|
||||
No output recorded for this run.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -44,6 +44,12 @@ type FileManagerTreeProps = {
|
||||
onSelect: (node: TreeNode) => void;
|
||||
onRefresh: () => void;
|
||||
compact?: boolean;
|
||||
/** Parent directory path for ".." navigation. Null when at filesystem root or in workspace mode without browsing. */
|
||||
parentDir?: string | null;
|
||||
/** Callback when user clicks ".." to navigate up. */
|
||||
onNavigateUp?: () => void;
|
||||
/** Current browse directory (absolute path), or null when in workspace mode. */
|
||||
browseDir?: string | null;
|
||||
};
|
||||
|
||||
// --- System file detection (client-side mirror) ---
|
||||
@ -600,7 +606,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
|
||||
|
||||
// --- Main Exported Component ---
|
||||
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact }: FileManagerTreeProps) {
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir }: FileManagerTreeProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
@ -999,6 +1005,36 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleEmptyContextMenu}
|
||||
>
|
||||
{/* ".." navigation entry for browsing up */}
|
||||
{parentDir != null && onNavigateUp && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={-1}
|
||||
onClick={onNavigateUp}
|
||||
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none"
|
||||
style={{
|
||||
paddingLeft: "8px",
|
||||
color: "var(--color-text-muted)",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
<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>
|
||||
</span>
|
||||
<span className="flex-shrink-0 flex items-center" style={{ color: "var(--color-text-muted)" }}>
|
||||
<FolderIcon />
|
||||
</span>
|
||||
<span className="truncate flex-1">..</span>
|
||||
</div>
|
||||
)}
|
||||
{tree.map((node) => (
|
||||
<DraggableNode
|
||||
key={node.path}
|
||||
|
||||
@ -10,6 +10,14 @@ type WorkspaceSidebarProps = {
|
||||
onRefresh: () => void;
|
||||
orgName?: string;
|
||||
loading?: boolean;
|
||||
/** Current browse directory (absolute path), or null when in workspace mode. */
|
||||
browseDir?: string | null;
|
||||
/** Parent directory for ".." navigation. Null at filesystem root or when browsing is unavailable. */
|
||||
parentDir?: string | null;
|
||||
/** Navigate up one directory. */
|
||||
onNavigateUp?: () => void;
|
||||
/** Return to workspace mode from browse mode. */
|
||||
onGoHome?: () => void;
|
||||
};
|
||||
|
||||
function WorkspaceLogo() {
|
||||
@ -50,6 +58,23 @@ function HomeIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function FolderOpenIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Theme toggle ─── */
|
||||
|
||||
function ThemeToggle() {
|
||||
@ -120,6 +145,12 @@ function ThemeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract the directory name from an absolute path for display. */
|
||||
function dirDisplayName(dir: string): string {
|
||||
if (dir === "/") {return "/";}
|
||||
return dir.split("/").pop() || dir;
|
||||
}
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
tree,
|
||||
activePath,
|
||||
@ -127,7 +158,13 @@ export function WorkspaceSidebar({
|
||||
onRefresh,
|
||||
orgName,
|
||||
loading,
|
||||
browseDir,
|
||||
parentDir,
|
||||
onNavigateUp,
|
||||
onGoHome,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex flex-col h-screen border-r flex-shrink-0"
|
||||
@ -142,31 +179,77 @@ export function WorkspaceSidebar({
|
||||
className="flex items-center gap-2.5 px-4 py-3 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
<WorkspaceLogo />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Ironclaw
|
||||
</div>
|
||||
</div>
|
||||
{isBrowsing ? (
|
||||
<>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
title={browseDir}
|
||||
>
|
||||
{dirDisplayName(browseDir)}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] truncate"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title={browseDir}
|
||||
>
|
||||
{browseDir}
|
||||
</div>
|
||||
</div>
|
||||
{/* Home button to return to workspace */}
|
||||
{onGoHome && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoHome}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Return to workspace"
|
||||
>
|
||||
<HomeIcon />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
<WorkspaceLogo />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{orgName || "Workspace"}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Ironclaw
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
@ -188,6 +271,9 @@ export function WorkspaceSidebar({
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
parentDir={parentDir}
|
||||
onNavigateUp={onNavigateUp}
|
||||
browseDir={browseDir}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -197,14 +283,26 @@ export function WorkspaceSidebar({
|
||||
className="px-3 py-2.5 border-t flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<HomeIcon />
|
||||
Home
|
||||
</a>
|
||||
{isBrowsing && onGoHome ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoHome}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<WorkspaceLogo />
|
||||
Workspace
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<HomeIcon />
|
||||
Home
|
||||
</a>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -14,23 +14,32 @@ export type TreeNode = {
|
||||
/**
|
||||
* Hook that fetches the workspace tree and subscribes to SSE file-change events
|
||||
* for live reactivity. Falls back to polling if SSE is unavailable.
|
||||
*
|
||||
* Supports a browse mode: when `browseDir` is set, the tree is fetched from
|
||||
* the browse API instead of the workspace tree API.
|
||||
*/
|
||||
export function useWorkspaceWatcher() {
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exists, setExists] = useState(false);
|
||||
|
||||
// Browse mode state
|
||||
const [browseDir, setBrowseDir] = useState<string | null>(null);
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const retryDelayRef = useRef(1000);
|
||||
|
||||
// Fetch the tree from the API
|
||||
const fetchTree = useCallback(async () => {
|
||||
// Fetch the workspace tree from the tree API
|
||||
const fetchWorkspaceTree = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/tree");
|
||||
const data = await res.json();
|
||||
if (mountedRef.current) {
|
||||
setTree(data.tree ?? []);
|
||||
setExists(data.exists ?? false);
|
||||
setWorkspaceRoot(data.workspaceRoot ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
@ -38,12 +47,38 @@ export function useWorkspaceWatcher() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch a directory listing from the browse API
|
||||
const fetchBrowseTree = useCallback(async (dir: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
|
||||
const data = await res.json();
|
||||
if (mountedRef.current) {
|
||||
setTree(data.entries ?? []);
|
||||
setParentDir(data.parentDir ?? null);
|
||||
setExists(true);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {setLoading(false);}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unified fetch based on current mode
|
||||
const fetchTree = useCallback(async () => {
|
||||
if (browseDir) {
|
||||
await fetchBrowseTree(browseDir);
|
||||
} else {
|
||||
await fetchWorkspaceTree();
|
||||
}
|
||||
}, [browseDir, fetchBrowseTree, fetchWorkspaceTree]);
|
||||
|
||||
// Manual refresh for use after mutations
|
||||
const refresh = useCallback(() => {
|
||||
fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
// Initial fetch
|
||||
// Re-fetch when browseDir changes
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchTree();
|
||||
@ -52,8 +87,10 @@ export function useWorkspaceWatcher() {
|
||||
};
|
||||
}, [fetchTree]);
|
||||
|
||||
// SSE subscription with auto-reconnect and polling fallback
|
||||
// SSE subscription -- only active in workspace mode (not browse mode)
|
||||
useEffect(() => {
|
||||
if (browseDir) {return;}
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@ -64,7 +101,7 @@ export function useWorkspaceWatcher() {
|
||||
function debouncedRefetch() {
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (alive) {fetchTree();}
|
||||
if (alive) {fetchWorkspaceTree();}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@ -124,7 +161,7 @@ export function useWorkspaceWatcher() {
|
||||
function startPolling() {
|
||||
if (pollInterval || !alive) {return;}
|
||||
pollInterval = setInterval(() => {
|
||||
if (alive) {fetchTree();}
|
||||
if (alive) {fetchWorkspaceTree();}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@ -137,7 +174,7 @@ export function useWorkspaceWatcher() {
|
||||
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
};
|
||||
}, [fetchTree]);
|
||||
}, [browseDir, fetchWorkspaceTree]);
|
||||
|
||||
return { tree, loading, exists, refresh };
|
||||
return { tree, loading, exists, refresh, browseDir, setBrowseDir, parentDir, workspaceRoot };
|
||||
}
|
||||
|
||||
@ -104,11 +104,28 @@ function isVirtualPath(path: string): boolean {
|
||||
return path.startsWith("~");
|
||||
}
|
||||
|
||||
/** Pick the right file API endpoint based on virtual vs real paths. */
|
||||
/** Detect absolute filesystem paths (browse mode). */
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith("/");
|
||||
}
|
||||
|
||||
/** Pick the right file API endpoint based on virtual vs real vs absolute paths. */
|
||||
function fileApiUrl(path: string): string {
|
||||
return isVirtualPath(path)
|
||||
? `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`
|
||||
: `/api/workspace/file?path=${encodeURIComponent(path)}`;
|
||||
if (isVirtualPath(path)) {
|
||||
return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
if (isAbsolutePath(path)) {
|
||||
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
return `/api/workspace/file?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** Pick the right raw file URL for media preview. */
|
||||
function rawFileUrl(path: string): string {
|
||||
if (isAbsolutePath(path)) {
|
||||
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
|
||||
}
|
||||
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** Find a node in the tree by exact path. */
|
||||
@ -197,8 +214,11 @@ function WorkspacePageInner() {
|
||||
// Chat panel ref for session management
|
||||
const chatRef = useRef<ChatPanelHandle>(null);
|
||||
|
||||
// Live-reactive tree via SSE watcher
|
||||
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
|
||||
// Live-reactive tree via SSE watcher (with browse-mode support)
|
||||
const {
|
||||
tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree,
|
||||
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot,
|
||||
} = useWorkspaceWatcher();
|
||||
|
||||
// Search index for @ mention fuzzy search (files + entries)
|
||||
const { search: searchIndex } = useSearchIndex();
|
||||
@ -333,8 +353,8 @@ function WorkspacePageInner() {
|
||||
// Check if this is a media file (image/video/audio/pdf)
|
||||
const mediaType = detectMediaType(node.name);
|
||||
if (mediaType) {
|
||||
const rawUrl = `/api/workspace/raw-file?path=${encodeURIComponent(node.path)}`;
|
||||
setContent({ kind: "media", url: rawUrl, mediaType, filename: node.name, filePath: node.path });
|
||||
const url = rawFileUrl(node.path);
|
||||
setContent({ kind: "media", url, mediaType, filename: node.name, filePath: node.path });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -399,7 +419,12 @@ function WorkspacePageInner() {
|
||||
);
|
||||
|
||||
// Build the enhanced tree: real tree + Chats + Cron virtual folders at the bottom
|
||||
// In browse mode, skip virtual folders (they only apply to workspace mode)
|
||||
const enhancedTree = useMemo(() => {
|
||||
if (browseDir) {
|
||||
return tree;
|
||||
}
|
||||
|
||||
const chatChildren: TreeNode[] = sessions.map((s) => ({
|
||||
name: s.title || "Untitled chat",
|
||||
path: `~chats/${s.id}`,
|
||||
@ -439,7 +464,34 @@ function WorkspacePageInner() {
|
||||
};
|
||||
|
||||
return [...tree, chatsFolder, cronFolder];
|
||||
}, [tree, sessions, cronJobs]);
|
||||
}, [tree, sessions, cronJobs, browseDir]);
|
||||
|
||||
// Compute the effective parentDir for ".." navigation.
|
||||
// In browse mode: use browseParentDir from the API.
|
||||
// In workspace mode: use the parent of the workspace root (allows escaping workspace).
|
||||
const effectiveParentDir = useMemo(() => {
|
||||
if (browseDir) {
|
||||
return browseParentDir;
|
||||
}
|
||||
// In workspace mode, allow ".." to go up from workspace root
|
||||
if (workspaceRoot) {
|
||||
const parent = workspaceRoot === "/" ? null : workspaceRoot.split("/").slice(0, -1).join("/") || "/";
|
||||
return parent;
|
||||
}
|
||||
return null;
|
||||
}, [browseDir, browseParentDir, workspaceRoot]);
|
||||
|
||||
// Handle ".." navigation
|
||||
const handleNavigateUp = useCallback(() => {
|
||||
if (effectiveParentDir != null) {
|
||||
setBrowseDir(effectiveParentDir);
|
||||
}
|
||||
}, [effectiveParentDir, setBrowseDir]);
|
||||
|
||||
// Return to workspace mode
|
||||
const handleGoHome = useCallback(() => {
|
||||
setBrowseDir(null);
|
||||
}, [setBrowseDir]);
|
||||
|
||||
// Sync URL bar with active content / chat state.
|
||||
// Uses window.location instead of searchParams in the comparison to
|
||||
@ -647,6 +699,10 @@ function WorkspacePageInner() {
|
||||
onRefresh={refreshTree}
|
||||
orgName={context?.organization?.name}
|
||||
loading={treeLoading}
|
||||
browseDir={browseDir}
|
||||
parentDir={effectiveParentDir}
|
||||
onNavigateUp={handleNavigateUp}
|
||||
onGoHome={handleGoHome}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user