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>
107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import { resolveDenchRoot } from "@/lib/workspace";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
/**
|
|
* GET /api/workspace/watch
|
|
*
|
|
* Server-Sent Events endpoint that watches the dench workspace for file changes.
|
|
* Sends events: { type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string }
|
|
* Falls back gracefully if chokidar is unavailable.
|
|
*/
|
|
export async function GET() {
|
|
const root = resolveDenchRoot();
|
|
if (!root) {
|
|
return new Response("Workspace not found", { status: 404 });
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
// Send initial heartbeat so the client knows the connection is alive
|
|
controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n"));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let watcher: any = null;
|
|
let closed = false;
|
|
|
|
// Debounce: batch rapid events into a single "refresh" signal
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function sendEvent(type: string, filePath: string) {
|
|
if (closed) {return;}
|
|
if (debounceTimer) {clearTimeout(debounceTimer);}
|
|
debounceTimer = setTimeout(() => {
|
|
if (closed) {return;}
|
|
try {
|
|
const data = JSON.stringify({ type, path: filePath });
|
|
controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`));
|
|
} catch {
|
|
// Stream may have been closed
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
// Keep-alive heartbeat every 30s to prevent proxy/timeout disconnects
|
|
const heartbeat = setInterval(() => {
|
|
if (closed) {return;}
|
|
try {
|
|
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
|
} catch {
|
|
// Ignore if closed
|
|
}
|
|
}, 30_000);
|
|
|
|
try {
|
|
// Dynamic import so the route still compiles if chokidar is missing
|
|
const chokidar = await import("chokidar");
|
|
watcher = chokidar.watch(root, {
|
|
ignoreInitial: true,
|
|
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
|
|
ignored: [
|
|
/(^|[\\/])node_modules([\\/]|$)/,
|
|
/\.duckdb\.wal$/,
|
|
/\.duckdb\.tmp$/,
|
|
],
|
|
depth: 10,
|
|
});
|
|
|
|
watcher.on("all", (eventType: string, filePath: string) => {
|
|
// Make path relative to workspace root
|
|
const rel = filePath.startsWith(root)
|
|
? filePath.slice(root.length + 1)
|
|
: filePath;
|
|
sendEvent(eventType, rel);
|
|
});
|
|
} catch {
|
|
// chokidar not available, send a fallback event and close
|
|
controller.enqueue(
|
|
encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"),
|
|
);
|
|
}
|
|
|
|
// Cleanup when the client disconnects
|
|
// The cancel callback is invoked by the runtime when the response is aborted
|
|
const originalCancel = stream.cancel?.bind(stream);
|
|
stream.cancel = async (reason) => {
|
|
closed = true;
|
|
clearInterval(heartbeat);
|
|
if (debounceTimer) {clearTimeout(debounceTimer);}
|
|
if (watcher) {await watcher.close();}
|
|
if (originalCancel) {return originalCancel(reason);}
|
|
};
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
});
|
|
}
|