/** * @file WorkspaceContext — a workspace's context: Instructions, Memory * (read-only), Context files and Folders (`GET/PUT/POST/DELETE * /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the * workspace home's right column and, `compact`, folded at the bottom of the * session view's Changes panel. Folder suggestions are the repo's untracked * worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the * same form clones related repos beside the workspace (`CloneSourcesForm`). * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { FileText, Folder, Pencil, Plus, X } from "lucide-react"; import { copyText } from "../../lib/clipboard"; import { dragProjectPath } from "../../lib/promptAttach"; import { useDialog } from "../../hooks/useDialog"; import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { MarkdownPreview } from "../MarkdownPreview"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { PathPickButton } from "../PathPickButton"; import { CloneSourcesForm } from "./CloneSourcesForm"; function fmtBytes(n: number | null): string { if (!n) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); /** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */ export function WorkspaceContext(props: Parameters[0]) { return ; } function ContextBody({ lane, onSourcesChange, sourcesVersion = 0, compact = false, }: { lane: Lane; /** Called with the sources after a folder or file is added or removed. */ onSourcesChange?: (view: LaneSourcesView) => void; /** Bumped when the folder list changed elsewhere: the lists re-read. */ sourcesVersion?: number; /** The Changes panel's narrow fold: no card, tighter rows. */ compact?: boolean; }) { const { t } = useTranslation("lanes"); const aiTools = useAiTools(); const prefs = useProjectTreePrefs(); const [error, setError] = useState(null); const [view, setView] = useState(null); const [editing, setEditing] = useState(null); const [memory, setMemory] = useState(null); const [memoryOpen, setMemoryOpen] = useState(false); const [memoryTab, setMemoryTab] = useState(0); const [folderOpen, setFolderOpen] = useState(false); const [folderPath, setFolderPath] = useState(""); const [suggestions, setSuggestions] = useState([]); const fileInput = useRef(null); const [ctxDrag, setCtxDrag] = useState(false); const memoryPanel = useRef(null); useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false)); const sourceCtx = useContextMenu(); useEffect(() => { let live = true; api.lanes .sources(lane.id) .then((v) => live && setView(v)) .catch((err) => live && setError(message(err))); api.lanes .memory(lane.id) .then((r) => live && setMemory(r.files)) .catch(() => live && setMemory([])); return () => { live = false; }; }, [lane.id]); // Suggestions are read when the folder form opens, not on every visit. useEffect(() => { if (!folderOpen) return; let live = true; api.projects .list() .then(({ projects }) => { if (!live) return; const p = projects.find((x) => x.workspaces.includes(lane.id)); const own = new Set((view?.sources ?? []).map((s) => s.path)); setSuggestions( !p || !prefs.showUntracked ? [] : p.worktrees .filter((w) => w.workspace_id === null && !own.has(w.path)) .filter((w) => !(w.source && prefs.hideSources.includes(w.source))) .map((w) => w.path) ); }) .catch(() => undefined); return () => { live = false; }; }, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]); useEffect(() => { if (!sourcesVersion) return; let live = true; api.lanes .sources(lane.id) .then((v) => live && setView(v)) .catch(() => undefined); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only }, [sourcesVersion]); const run = async (fn: () => Promise) => { setError(null); try { const next = await fn(); setView(next); onSourcesChange?.(next); return true; } catch (err) { setError(message(err)); return false; } }; const addFiles = async (picked: File[]) => { for (const f of picked) { if (!(await run(() => api.lanes.addFile(lane.id, f)))) break; } }; const files = (view?.sources ?? []).filter((s) => s.kind === "file"); const folders = (view?.sources ?? []).filter((s) => s.kind === "folder"); const card = compact ? "border-b border-border/60 px-1 py-2 last:border-b-0" : "border-b border-border px-4 py-4 last:border-b-0"; const heading = compact ? "text-xs font-medium text-fg-secondary" : "text-sm font-medium text-fg-primary"; const iconBtn = "rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"; return ( <>