File Manager & Filesystem Operations: - Add FileManagerTree component with drag-and-drop (dnd-kit), inline rename, right-click context menu, and compact sidebar mode - Add context-menu component (open, new file/folder, rename, duplicate, copy, paste, move, delete) rendered via portal - Add InlineRename component with validation and shake-on-error animation - Add useWorkspaceWatcher hook with SSE live-reload and polling fallback - Add API routes: mkdir, rename, copy, move, watch (SSE file-change events), and DELETE on /api/workspace/file with system-file protection - Add safeResolveNewPath and isSystemFile helpers to workspace lib - Replace inline WorkspaceTreeNode in sidebar with shared FileManagerTree (compact mode), add workspace refresh callback Object Relation Resolution: - Resolve relation fields to human-readable display labels server-side (resolveRelationLabels, resolveDisplayField helpers) - Add reverse relation discovery (findReverseRelations) — surfaces incoming links from other objects - Add display_field column migration (idempotent ALTER TABLE) and PATCH /api/workspace/objects/[name]/display-field endpoint - Enrich object API response with relationLabels, reverseRelations, effectiveDisplayField, and related_object_name per field - Add RelationCell, RelationChip, ReverseRelationCell, LinkIcon components to object-table with clickable cross-object navigation - Add relation label rendering to kanban cards - Extract ObjectView component in workspace page with display-field selector dropdown and relation/reverse-relation badge counts Chat Panel Extraction: - Extract chat logic from page.tsx into standalone ChatPanel component with forwardRef/useImperativeHandle for session control - ChatPanel supports file-scoped sessions (filePath param) and context-aware file chat sidebar - Simplify page.tsx to thin orchestrator delegating to ChatPanel - Add filePath filter to GET /api/web-sessions for scoped session lists Dependencies: - Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities - Add duckdbExec and parseRelationValue to workspace lib Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
3.9 KiB
TypeScript
144 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
|
export type TreeNode = {
|
|
name: string;
|
|
path: string;
|
|
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
|
icon?: string;
|
|
defaultView?: "table" | "kanban";
|
|
children?: 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.
|
|
*/
|
|
export function useWorkspaceWatcher() {
|
|
const [tree, setTree] = useState<TreeNode[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [exists, setExists] = useState(false);
|
|
|
|
const mountedRef = useRef(true);
|
|
const retryDelayRef = useRef(1000);
|
|
|
|
// Fetch the tree from the API
|
|
const fetchTree = 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);
|
|
setLoading(false);
|
|
}
|
|
} catch {
|
|
if (mountedRef.current) {setLoading(false);}
|
|
}
|
|
}, []);
|
|
|
|
// Manual refresh for use after mutations
|
|
const refresh = useCallback(() => {
|
|
fetchTree();
|
|
}, [fetchTree]);
|
|
|
|
// Initial fetch
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
fetchTree();
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, [fetchTree]);
|
|
|
|
// SSE subscription with auto-reconnect and polling fallback
|
|
useEffect(() => {
|
|
let eventSource: EventSource | null = null;
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let alive = true;
|
|
|
|
// Debounce rapid SSE events into a single tree refetch
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function debouncedRefetch() {
|
|
if (debounceTimer) {clearTimeout(debounceTimer);}
|
|
debounceTimer = setTimeout(() => {
|
|
if (alive) {fetchTree();}
|
|
}, 300);
|
|
}
|
|
|
|
function connectSSE() {
|
|
if (!alive) {return;}
|
|
|
|
try {
|
|
eventSource = new EventSource("/api/workspace/watch");
|
|
|
|
eventSource.addEventListener("connected", () => {
|
|
// Reset retry delay on successful connection
|
|
retryDelayRef.current = 1000;
|
|
// Stop polling fallback if it was active
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener("change", () => {
|
|
debouncedRefetch();
|
|
});
|
|
|
|
eventSource.addEventListener("error", () => {
|
|
// SSE errored -- close and schedule reconnect
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
scheduleReconnect();
|
|
});
|
|
|
|
eventSource.onerror = () => {
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
scheduleReconnect();
|
|
};
|
|
} catch {
|
|
// SSE not supported or network error -- fall back to polling
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (!alive) {return;}
|
|
// Start polling as fallback while we wait to reconnect
|
|
startPolling();
|
|
const delay = retryDelayRef.current;
|
|
retryDelayRef.current = Math.min(delay * 2, 30_000);
|
|
reconnectTimeout = setTimeout(() => {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
connectSSE();
|
|
}, delay);
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollInterval || !alive) {return;}
|
|
pollInterval = setInterval(() => {
|
|
if (alive) {fetchTree();}
|
|
}, 5000);
|
|
}
|
|
|
|
connectSSE();
|
|
|
|
return () => {
|
|
alive = false;
|
|
if (eventSource) {eventSource.close();}
|
|
if (pollInterval) {clearInterval(pollInterval);}
|
|
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
|
|
if (debounceTimer) {clearTimeout(debounceTimer);}
|
|
};
|
|
}, [fetchTree]);
|
|
|
|
return { tree, loading, exists, refresh };
|
|
}
|