singularity-forge/web/components/sf/files-view.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

1728 lines
47 KiB
TypeScript

"use client";
import {
AlertCircle,
Bot,
ChevronDown,
ChevronRight,
ClipboardCopy,
Copy,
File,
FileCode,
FilePlus,
FileText,
Folder,
FolderOpen,
FolderPlus,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChatPane } from "@/components/sf/chat-mode";
import { FileContentViewer } from "@/components/sf/file-content-viewer";
import { authFetch } from "@/lib/auth";
import { buildProjectUrl, useSFWorkspaceState } from "@/lib/sf-workspace-store";
import { cn } from "@/lib/utils";
type RootMode = "sf" | "project";
// Global pending file request — survives across component mount/unmount cycles.
// Set by the custom event, consumed by FilesView on mount or when already mounted.
let pendingFileRequest: { root: RootMode; path: string } | null = null;
// Set up the global event listener once (module-level, not component-level)
if (typeof window !== "undefined") {
window.addEventListener("sf:open-file", (e: Event) => {
const detail = (e as CustomEvent<{ root: RootMode; path: string }>).detail;
if (detail?.root && detail?.path) {
pendingFileRequest = { root: detail.root, path: detail.path };
}
});
}
interface FileNode {
name: string;
type: "file" | "directory";
children?: FileNode[];
}
/* ── Persistence helpers ── */
function storageKey(projectCwd: string, root: RootMode): string {
return `sf-files-expanded:${root}:${projectCwd}`;
}
function loadExpanded(
projectCwd: string | undefined,
root: RootMode,
): Set<string> {
if (!projectCwd) return new Set();
try {
const raw = sessionStorage.getItem(storageKey(projectCwd, root));
if (raw) return new Set(JSON.parse(raw) as string[]);
} catch {
/* ignore */
}
return new Set();
}
function saveExpanded(
projectCwd: string | undefined,
root: RootMode,
expanded: Set<string>,
): void {
if (!projectCwd) return;
try {
sessionStorage.setItem(
storageKey(projectCwd, root),
JSON.stringify([...expanded]),
);
} catch {
/* ignore */
}
}
/* ── Icons ── */
function FileIcon({
name,
isFolder,
isOpen,
}: {
name: string;
isFolder: boolean;
isOpen?: boolean;
}) {
if (isFolder) {
return isOpen ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
);
}
if (name.endsWith(".md")) {
return <FileText className="h-4 w-4 text-muted-foreground" />;
}
if (
name.endsWith(".json") ||
name.endsWith(".ts") ||
name.endsWith(".tsx") ||
name.endsWith(".js") ||
name.endsWith(".jsx")
) {
return <FileCode className="h-4 w-4 text-muted-foreground" />;
}
return <File className="h-4 w-4 text-muted-foreground" />;
}
/* ── Context menu ── */
interface ContextMenuState {
x: number;
y: number;
path: string;
type: "file" | "directory";
/** parent directory path (empty string = root) */
parentPath: string;
}
interface ContextMenuProps {
menu: ContextMenuState;
onClose: () => void;
onNewFile: (parentDir: string) => void;
onNewFolder: (parentDir: string) => void;
onRename: (path: string) => void;
onDelete: (path: string, type: "file" | "directory") => void;
onCopyPath: (path: string) => void;
onDuplicate: (path: string) => void;
}
function TreeContextMenu({
menu,
onClose,
onNewFile,
onNewFolder,
onRename,
onDelete,
onCopyPath,
onDuplicate,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// Close on click outside or escape
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
// Keep menu within viewport
const [pos, setPos] = useState({ x: menu.x, y: menu.y });
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
let { x, y } = menu;
if (x + rect.width > window.innerWidth)
x = window.innerWidth - rect.width - 8;
if (y + rect.height > window.innerHeight)
y = window.innerHeight - rect.height - 8;
if (x < 0) x = 8;
if (y < 0) y = 8;
setPos({ x, y });
}, [menu]);
const parentDir = menu.type === "directory" ? menu.path : menu.parentPath;
const items: {
label: string;
icon: React.ReactNode;
action: () => void;
destructive?: boolean;
separator?: boolean;
}[] = [
{
label: "New File",
icon: <FilePlus className="h-3.5 w-3.5" />,
action: () => {
onNewFile(parentDir);
onClose();
},
},
{
label: "New Folder",
icon: <FolderPlus className="h-3.5 w-3.5" />,
action: () => {
onNewFolder(parentDir);
onClose();
},
},
{
label: "Rename",
icon: <Pencil className="h-3.5 w-3.5" />,
action: () => {
onRename(menu.path);
onClose();
},
separator: true,
},
{
label: "Duplicate",
icon: <Copy className="h-3.5 w-3.5" />,
action: () => {
onDuplicate(menu.path);
onClose();
},
},
{
label: "Copy Path",
icon: <ClipboardCopy className="h-3.5 w-3.5" />,
action: () => {
onCopyPath(menu.path);
onClose();
},
separator: true,
},
{
label: "Delete",
icon: <Trash2 className="h-3.5 w-3.5" />,
action: () => {
onDelete(menu.path, menu.type);
onClose();
},
destructive: true,
},
];
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-lg animate-in fade-in-0 zoom-in-95"
style={{ left: pos.x, top: pos.y }}
>
{items.map((item, i) => (
<div key={i}>
{item.separator && i > 0 && <div className="my-1 h-px bg-border" />}
<button
type="button"
onClick={item.action}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-xs transition-colors",
item.destructive
? "text-destructive hover:bg-destructive/10"
: "text-popover-foreground hover:bg-accent",
)}
>
{item.icon}
{item.label}
</button>
</div>
))}
</div>
);
}
/* ── Inline input (for rename / new file / new folder) ── */
function InlineInput({
defaultValue,
onCommit,
onCancel,
depth,
icon,
}: {
defaultValue: string;
onCommit: (value: string) => void;
onCancel: () => void;
depth: number;
icon: React.ReactNode;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Focus and select just the filename (not extension) on mount
const input = inputRef.current;
if (!input) return;
input.focus();
const dotIndex = defaultValue.lastIndexOf(".");
if (dotIndex > 0) {
input.setSelectionRange(0, dotIndex);
} else {
input.select();
}
}, [defaultValue]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
const val = inputRef.current?.value.trim();
if (val && val.length > 0) onCommit(val);
else onCancel();
}
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<div
className="flex items-center gap-1.5 px-2 py-0.5"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{icon}
<input
ref={inputRef}
defaultValue={defaultValue}
onKeyDown={handleKeyDown}
onBlur={() => {
const val = inputRef.current?.value.trim();
if (val && val.length > 0) onCommit(val);
else onCancel();
}}
className="flex-1 bg-transparent text-sm outline-none border border-ring rounded px-1 py-0.5 text-foreground"
spellCheck={false}
/>
</div>
);
}
/* ── Tree item ── */
interface FileTreeItemProps {
node: FileNode;
depth: number;
parentPath: string;
selectedPath: string | null;
expandedPaths: Set<string>;
renamingPath: string | null;
creatingIn: { parentDir: string; type: "file" | "directory" } | null;
onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void;
onMoveFile: (fromPath: string, toDir: string) => void;
onContextMenu: (
e: React.MouseEvent,
path: string,
type: "file" | "directory",
parentPath: string,
) => void;
onRenameCommit: (oldPath: string, newName: string) => void;
onRenameCancel: () => void;
onCreateCommit: (
parentDir: string,
name: string,
type: "file" | "directory",
) => void;
onCreateCancel: () => void;
}
function FileTreeItem({
node,
depth,
parentPath,
selectedPath,
expandedPaths,
renamingPath,
creatingIn,
onToggleDir,
onSelectFile,
onMoveFile,
onContextMenu,
onRenameCommit,
onRenameCancel,
onCreateCommit,
onCreateCancel,
}: FileTreeItemProps) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
const isOpen = node.type === "directory" && expandedPaths.has(fullPath);
const [dragOver, setDragOver] = useState(false);
const isRenaming = renamingPath === fullPath;
// Should we show the "create new" input inside this directory?
const showCreateInput =
creatingIn &&
creatingIn.parentDir === fullPath &&
node.type === "directory" &&
isOpen;
const handleClick = () => {
if (node.type === "directory") {
onToggleDir(fullPath);
} else {
onSelectFile(fullPath);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, fullPath, node.type, parentPath);
};
// ── Drag source ──
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData("text/x-tree-path", fullPath);
e.dataTransfer.effectAllowed = "move";
};
// ── Drop target (directories only) ──
const handleDragOver = (e: React.DragEvent) => {
if (node.type !== "directory") return;
const srcPath = e.dataTransfer.types.includes("text/x-tree-path")
? "pending"
: null;
if (!srcPath) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
setDragOver(false);
if (node.type !== "directory") return;
e.preventDefault();
const srcPath = e.dataTransfer.getData("text/x-tree-path");
if (!srcPath || srcPath === fullPath) return;
if (fullPath.startsWith(srcPath + "/")) return;
const srcParent = srcPath.includes("/")
? srcPath.substring(0, srcPath.lastIndexOf("/"))
: "";
if (srcParent === fullPath) return;
onMoveFile(srcPath, fullPath);
};
// Inline rename mode
if (isRenaming) {
return (
<div data-tree-item>
<InlineInput
defaultValue={node.name}
onCommit={(newName) => onRenameCommit(fullPath, newName)}
onCancel={onRenameCancel}
depth={depth}
icon={
<FileIcon
name={node.name}
isFolder={node.type === "directory"}
isOpen={isOpen}
/>
}
/>
</div>
);
}
return (
<div data-tree-item>
<button
type="button"
onClick={handleClick}
onContextMenu={handleContextMenu}
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex w-full items-center gap-1.5 px-2 py-1 text-sm hover:bg-accent/50 transition-colors",
selectedPath === fullPath && node.type === "file" && "bg-accent",
dragOver && "bg-accent/70 outline outline-1 outline-ring",
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{node.type === "directory" &&
(isOpen ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
))}
<FileIcon
name={node.name}
isFolder={node.type === "directory"}
isOpen={isOpen}
/>
<span className="truncate">{node.name}</span>
</button>
{isOpen && node.children && (
<div>
{/* Create new item input at the top of the directory */}
{showCreateInput && (
<InlineInput
defaultValue={
creatingIn!.type === "directory" ? "new-folder" : "new-file"
}
onCommit={(name) =>
onCreateCommit(fullPath, name, creatingIn!.type)
}
onCancel={onCreateCancel}
depth={depth + 1}
icon={
creatingIn!.type === "directory" ? (
<Folder className="h-4 w-4 text-muted-foreground" />
) : (
<File className="h-4 w-4 text-muted-foreground" />
)
}
/>
)}
{node.children.map((child, i) => (
<FileTreeItem
key={i}
node={child}
depth={depth + 1}
parentPath={fullPath}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
renamingPath={renamingPath}
creatingIn={creatingIn}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onMoveFile={onMoveFile}
onContextMenu={onContextMenu}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
onCreateCommit={onCreateCommit}
onCreateCancel={onCreateCancel}
/>
))}
</div>
)}
</div>
);
}
/* ── Open tab model ── */
interface OpenTab {
/** Unique key: "root:path" */
key: string;
root: RootMode;
path: string;
content: string | null;
loading: boolean;
error: string | null;
/** When set, the viewer shows an inline diff overlay */
diff?: { before: string; after: string } | null;
/** Set when the agent just opened/edited this file — causes MD files to default to Edit tab */
agentOpened?: boolean;
}
function tabKey(root: RootMode, path: string): string {
return `${root}:${path}`;
}
function tabDisplayPath(tab: OpenTab): string {
return tab.root === "sf" ? `.sf/${tab.path}` : tab.path;
}
function tabLabel(tab: OpenTab): string {
return tab.path.split("/").pop() ?? tab.path;
}
/* ── Main view ── */
type LeftPanel = "tree" | "agent";
export function FilesView() {
const workspace = useSFWorkspaceState();
const projectCwd = workspace.boot?.project.cwd;
const [activeRoot, setActiveRoot] = useState<RootMode>("sf");
const [leftPanel, setLeftPanel] = useState<LeftPanel>("tree");
const [sfTree, setSfTree] = useState<FileNode[] | null>(null);
const [projectTree, setProjectTree] = useState<FileNode[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Resizable tree panel ──
const [treeWidth, setTreeWidth] = useState(256);
const isDraggingTree = useRef(false);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingTree.current) return;
const delta = e.clientX - dragStartX.current;
const newWidth = Math.max(
180,
Math.min(480, dragStartWidth.current + delta),
);
setTreeWidth(newWidth);
};
const handleMouseUp = () => {
if (isDraggingTree.current) {
isDraggingTree.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, []);
const handleTreeDragStart = useCallback(
(e: React.MouseEvent) => {
isDraggingTree.current = true;
dragStartX.current = e.clientX;
dragStartWidth.current = treeWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[treeWidth],
);
// Expanded paths per root, restored from sessionStorage
const [sfExpanded, setSfExpanded] = useState<Set<string>>(() =>
loadExpanded(projectCwd, "sf"),
);
const [projectExpanded, setProjectExpanded] = useState<Set<string>>(() =>
loadExpanded(projectCwd, "project"),
);
// Re-hydrate from storage once projectCwd is available (boot may arrive after first render)
const hydratedRef = useRef(false);
useEffect(() => {
if (!projectCwd || hydratedRef.current) return;
hydratedRef.current = true;
setSfExpanded(loadExpanded(projectCwd, "sf"));
setProjectExpanded(loadExpanded(projectCwd, "project"));
}, [projectCwd]);
const expandedPaths = activeRoot === "sf" ? sfExpanded : projectExpanded;
const setExpandedPaths =
activeRoot === "sf" ? setSfExpanded : setProjectExpanded;
// ── Multi-tab state ──
const [openTabs, setOpenTabs] = useState<OpenTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState<string | null>(null);
const [treeRootDragOver, setTreeRootDragOver] = useState(false);
// ── Context menu state ──
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [creatingIn, setCreatingIn] = useState<{
parentDir: string;
type: "file" | "directory";
} | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
path: string;
type: "file" | "directory";
} | null>(null);
const activeTab = openTabs.find((t) => t.key === activeTabKey) ?? null;
// The selected path in the tree corresponds to the active tab
const selectedPath = activeTab?.path ?? null;
const tree = activeRoot === "sf" ? sfTree : projectTree;
const treeLoaded =
activeRoot === "sf" ? sfTree !== null : projectTree !== null;
const fetchTree = useCallback(
async (root: RootMode) => {
try {
setLoading(true);
setError(null);
const res = await authFetch(
buildProjectUrl(`/api/files?root=${root}`, projectCwd),
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(
data.error || `Failed to fetch files (${res.status})`,
);
}
const data = await res.json();
const nodes = data.tree ?? [];
if (root === "sf") {
setSfTree(nodes);
} else {
setProjectTree(nodes);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch files");
} finally {
setLoading(false);
}
},
[projectCwd],
);
// Fetch tree when tab changes and data isn't cached
useEffect(() => {
if (!treeLoaded) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
fetchTree(activeRoot);
}
}, [activeRoot, treeLoaded, fetchTree]);
// Initial load
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
fetchTree("sf");
}, [fetchTree]);
// ── Open or focus a file tab and fetch its content ──
const openFileTab = useCallback(
async (root: RootMode, path: string) => {
const key = tabKey(root, path);
// If already open, just focus it
setOpenTabs((prev) => {
const existing = prev.find((t) => t.key === key);
if (existing) return prev;
// Add new tab
return [
...prev,
{ key, root, path, content: null, loading: true, error: null },
];
});
setActiveTabKey(key);
// Switch tree root to match
setActiveRoot(root);
// Auto-expand parent dirs
const parts = path.split("/");
const setExpanded = root === "sf" ? setSfExpanded : setProjectExpanded;
setExpanded((prev) => {
const next = new Set(prev);
for (let i = 1; i < parts.length; i++) {
next.add(parts.slice(0, i).join("/"));
}
saveExpanded(projectCwd, root, next);
return next;
});
// Check if we already have the content cached
setOpenTabs((prev) => {
const existing = prev.find((t) => t.key === key);
if (existing && existing.content !== null) return prev; // already loaded
return prev; // will fetch below
});
// Fetch content
try {
const res = await authFetch(
buildProjectUrl(
`/api/files?root=${root}&path=${encodeURIComponent(path)}`,
projectCwd,
),
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const errMsg = data.error || `Failed to fetch file (${res.status})`;
setOpenTabs((prev) =>
prev.map((t) =>
t.key === key ? { ...t, loading: false, error: errMsg } : t,
),
);
return;
}
const data = await res.json();
setOpenTabs((prev) =>
prev.map((t) =>
t.key === key
? {
...t,
content: data.content ?? null,
loading: false,
error: null,
}
: t,
),
);
} catch (err) {
const errMsg =
err instanceof Error ? err.message : "Failed to fetch file content";
setOpenTabs((prev) =>
prev.map((t) =>
t.key === key ? { ...t, loading: false, error: errMsg } : t,
),
);
}
},
[projectCwd],
);
// ── Close a tab ──
const closeTab = useCallback(
(key: string, e?: React.MouseEvent) => {
e?.stopPropagation();
setOpenTabs((prev) => {
const idx = prev.findIndex((t) => t.key === key);
const next = prev.filter((t) => t.key !== key);
// If we're closing the active tab, switch to an adjacent one
if (key === activeTabKey) {
if (next.length === 0) {
setActiveTabKey(null);
} else {
// Prefer the tab to the right, then left
const newIdx = Math.min(idx, next.length - 1);
setActiveTabKey(next[newIdx].key);
}
}
return next;
});
},
[activeTabKey],
);
// Process a file open request (used both on mount and on event)
const processFileOpen = useCallback(
async (root: RootMode, path: string) => {
// Ensure tree is loaded for this root
if (root === "sf" && !sfTree) {
fetchTree("sf");
} else if (root === "project" && !projectTree) {
fetchTree("project");
}
await openFileTab(root, path);
},
[sfTree, projectTree, fetchTree, openFileTab],
);
// On mount: consume any pending file request that arrived before this component mounted
const consumedPendingRef = useRef(false);
useEffect(() => {
if (consumedPendingRef.current) return;
if (pendingFileRequest) {
consumedPendingRef.current = true;
const { root, path } = pendingFileRequest;
pendingFileRequest = null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async, setState runs after await
void processFileOpen(root, path);
}
}, [processFileOpen]);
// Listen for file open events while mounted
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ root: RootMode; path: string }>)
.detail;
if (!detail?.root || !detail?.path) return;
pendingFileRequest = null; // clear since we're handling it directly
void processFileOpen(detail.root, detail.path);
};
window.addEventListener("sf:open-file", handler);
return () => window.removeEventListener("sf:open-file", handler);
}, [processFileOpen]);
const handleToggleDir = useCallback(
(path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
saveExpanded(projectCwd, activeRoot, next);
return next;
});
},
[setExpandedPaths, projectCwd, activeRoot],
);
const handleTreeRootChange = (root: RootMode) => {
setActiveRoot(root);
};
const handleSelectFile = useCallback(
async (path: string) => {
await openFileTab(activeRoot, path);
},
[activeRoot, openFileTab],
);
// ── Move file/directory via drag-and-drop ──
const handleMoveFile = useCallback(
async (fromPath: string, toDir: string) => {
const fileName = fromPath.split("/").pop() ?? fromPath;
const toPath = toDir ? `${toDir}/${fileName}` : fileName;
try {
const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: fromPath,
to: toPath,
root: activeRoot,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("Move failed:", data.error || res.statusText);
return;
}
// Update any open tabs that referenced the moved path
const oldKey = tabKey(activeRoot, fromPath);
setOpenTabs((prev) =>
prev.map((t) => {
if (t.key === oldKey) {
const newKey = tabKey(activeRoot, toPath);
return { ...t, key: newKey, path: toPath };
}
// Also update tabs for files inside a moved directory
if (t.root === activeRoot && t.path.startsWith(fromPath + "/")) {
const newTabPath = toPath + t.path.slice(fromPath.length);
return {
...t,
key: tabKey(activeRoot, newTabPath),
path: newTabPath,
};
}
return t;
}),
);
if (activeTabKey?.startsWith(`${activeRoot}:${fromPath}`)) {
if (activeTabKey === `${activeRoot}:${fromPath}`) {
setActiveTabKey(tabKey(activeRoot, toPath));
} else {
const suffix = activeTabKey.slice(
`${activeRoot}:${fromPath}`.length,
);
setActiveTabKey(tabKey(activeRoot, toPath + suffix));
}
}
// Refresh tree
await fetchTree(activeRoot);
} catch (err) {
console.error("Move failed:", err);
}
},
[activeRoot, activeTabKey, fetchTree, projectCwd],
);
// ── Context menu handlers ──
const handleContextMenu = useCallback(
(
e: React.MouseEvent,
path: string,
type: "file" | "directory",
parentPath: string,
) => {
setContextMenu({ x: e.clientX, y: e.clientY, path, type, parentPath });
},
[],
);
const handleContextMenuClose = useCallback(() => {
setContextMenu(null);
}, []);
const handleNewFile = useCallback(
(parentDir: string) => {
// Ensure parent directory is expanded
if (parentDir) {
const setExpanded =
activeRoot === "sf" ? setSfExpanded : setProjectExpanded;
setExpanded((prev) => {
const next = new Set(prev);
const parts = parentDir.split("/");
for (let i = 1; i <= parts.length; i++) {
next.add(parts.slice(0, i).join("/"));
}
saveExpanded(projectCwd, activeRoot, next);
return next;
});
}
setCreatingIn({ parentDir, type: "file" });
},
[activeRoot, projectCwd],
);
const handleNewFolder = useCallback(
(parentDir: string) => {
if (parentDir) {
const setExpanded =
activeRoot === "sf" ? setSfExpanded : setProjectExpanded;
setExpanded((prev) => {
const next = new Set(prev);
const parts = parentDir.split("/");
for (let i = 1; i <= parts.length; i++) {
next.add(parts.slice(0, i).join("/"));
}
saveExpanded(projectCwd, activeRoot, next);
return next;
});
}
setCreatingIn({ parentDir, type: "directory" });
},
[activeRoot, projectCwd],
);
const handleCreateCommit = useCallback(
async (parentDir: string, name: string, type: "file" | "directory") => {
const newPath = parentDir ? `${parentDir}/${name}` : name;
try {
const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: newPath, type, root: activeRoot }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("Create failed:", data.error || res.statusText);
return;
}
await fetchTree(activeRoot);
// Open the file if it's a file
if (type === "file") {
await openFileTab(activeRoot, newPath);
}
} catch (err) {
console.error("Create failed:", err);
} finally {
setCreatingIn(null);
}
},
[activeRoot, fetchTree, openFileTab, projectCwd],
);
const handleCreateCancel = useCallback(() => {
setCreatingIn(null);
}, []);
const handleRenameStart = useCallback((path: string) => {
setRenamingPath(path);
}, []);
const handleRenameCommit = useCallback(
async (oldPath: string, newName: string) => {
const parentDir = oldPath.includes("/")
? oldPath.substring(0, oldPath.lastIndexOf("/"))
: "";
const newPath = parentDir ? `${parentDir}/${newName}` : newName;
if (newPath === oldPath) {
setRenamingPath(null);
return;
}
try {
const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: oldPath,
to: newPath,
root: activeRoot,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("Rename failed:", data.error || res.statusText);
return;
}
// Update open tabs
const oldKey = tabKey(activeRoot, oldPath);
setOpenTabs((prev) =>
prev.map((t) => {
if (t.key === oldKey) {
return { ...t, key: tabKey(activeRoot, newPath), path: newPath };
}
if (t.root === activeRoot && t.path.startsWith(oldPath + "/")) {
const newTabPath = newPath + t.path.slice(oldPath.length);
return {
...t,
key: tabKey(activeRoot, newTabPath),
path: newTabPath,
};
}
return t;
}),
);
if (activeTabKey === `${activeRoot}:${oldPath}`) {
setActiveTabKey(tabKey(activeRoot, newPath));
} else if (activeTabKey?.startsWith(`${activeRoot}:${oldPath}/`)) {
const suffix = activeTabKey.slice(`${activeRoot}:${oldPath}`.length);
setActiveTabKey(tabKey(activeRoot, newPath + suffix));
}
await fetchTree(activeRoot);
} catch (err) {
console.error("Rename failed:", err);
} finally {
setRenamingPath(null);
}
},
[activeRoot, activeTabKey, fetchTree, projectCwd],
);
const handleRenameCancel = useCallback(() => {
setRenamingPath(null);
}, []);
const handleDelete = useCallback(
(path: string, type: "file" | "directory") => {
setDeleteConfirm({ path, type });
},
[],
);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirm) return;
const { path } = deleteConfirm;
try {
const res = await fetch(
buildProjectUrl(
`/api/files?root=${activeRoot}&path=${encodeURIComponent(path)}`,
projectCwd,
),
{ method: "DELETE" },
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("Delete failed:", data.error || res.statusText);
return;
}
// Close any tabs for the deleted path
setOpenTabs((prev) => {
const next = prev.filter((t) => {
if (t.root !== activeRoot) return true;
if (t.path === path) return false;
if (t.path.startsWith(path + "/")) return false;
return true;
});
// If active tab was removed, switch to adjacent
if (activeTabKey) {
const wasRemoved = !next.some((t) => t.key === activeTabKey);
if (wasRemoved) {
setActiveTabKey(next.length > 0 ? next[next.length - 1].key : null);
}
}
return next;
});
await fetchTree(activeRoot);
} catch (err) {
console.error("Delete failed:", err);
} finally {
setDeleteConfirm(null);
}
}, [deleteConfirm, activeRoot, activeTabKey, fetchTree, projectCwd]);
const handleDeleteCancel = useCallback(() => {
setDeleteConfirm(null);
}, []);
const handleCopyPath = useCallback(
(path: string) => {
const displayPath = activeRoot === "sf" ? `.sf/${path}` : path;
void navigator.clipboard.writeText(displayPath);
},
[activeRoot],
);
const handleDuplicate = useCallback(
async (path: string) => {
// Read original content
try {
const res = await authFetch(
buildProjectUrl(
`/api/files?root=${activeRoot}&path=${encodeURIComponent(path)}`,
projectCwd,
),
);
if (!res.ok) return;
const data = await res.json();
if (typeof data.content !== "string") return;
// Compute duplicate name: file.ts -> file-copy.ts, folder -> folder-copy
const fileName = path.split("/").pop() ?? path;
const parentDir = path.includes("/")
? path.substring(0, path.lastIndexOf("/"))
: "";
const dotIndex = fileName.lastIndexOf(".");
let newName: string;
if (dotIndex > 0) {
newName = `${fileName.substring(0, dotIndex)}-copy${fileName.substring(dotIndex)}`;
} else {
newName = `${fileName}-copy`;
}
const newPath = parentDir ? `${parentDir}/${newName}` : newName;
// Create with content
const createRes = await authFetch(
buildProjectUrl("/api/files", projectCwd),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: newPath,
content: data.content,
root: activeRoot,
}),
},
);
if (!createRes.ok) {
const errData = await createRes.json().catch(() => ({}));
console.error(
"Duplicate failed:",
errData.error || createRes.statusText,
);
return;
}
await fetchTree(activeRoot);
await openFileTab(activeRoot, newPath);
} catch (err) {
console.error("Duplicate failed:", err);
}
},
[activeRoot, fetchTree, openFileTab, projectCwd],
);
// Save handler: POST to /api/files, then re-fetch content
const handleSave = useCallback(
async (newContent: string) => {
if (!activeTab) return;
const { root, path, key } = activeTab;
const res = await authFetch(buildProjectUrl("/api/files", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content: newContent, root }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Save failed (${res.status})`);
}
// Re-fetch to sync the view tab
const refetch = await authFetch(
buildProjectUrl(
`/api/files?root=${root}&path=${encodeURIComponent(path)}`,
projectCwd,
),
);
if (refetch.ok) {
const data = await refetch.json();
setOpenTabs((prev) =>
prev.map((t) =>
t.key === key ? { ...t, content: data.content ?? null } : t,
),
);
}
},
[activeTab, projectCwd],
);
// Auto-select STATE.md on initial load if no tabs are open
const autoSelectedRef = useRef(false);
useEffect(() => {
if (autoSelectedRef.current) return;
if (!sfTree || openTabs.length > 0 || consumedPendingRef.current) return;
const hasStateMd = sfTree.some(
(n) => n.name === "STATE.md" && n.type === "file",
);
if (hasStateMd) {
autoSelectedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async, setState runs after await
void openFileTab("sf", "STATE.md");
}
}, [sfTree, openTabs.length, openFileTab]);
// ── Agent file-edit auto-open: watch tool executions for edit/write tools ──
const lastSeenToolCountRef = useRef(0);
const completedTools = workspace.completedToolExecutions;
const activeToolExec = workspace.activeToolExecution;
const diffTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (completedTools.length <= lastSeenToolCountRef.current) return;
const newTools = completedTools.slice(lastSeenToolCountRef.current);
lastSeenToolCountRef.current = completedTools.length;
for (const tool of newTools) {
if (tool.name !== "edit" && tool.name !== "write") continue;
const filePath =
typeof tool.args?.path === "string" ? tool.args.path : null;
if (!filePath) continue;
// Determine root and relative path
const sfPrefix = ".sf/";
let root: RootMode = "project";
let relativePath = filePath;
// Strip leading project cwd if present
if (projectCwd && relativePath.startsWith(projectCwd)) {
relativePath = relativePath.slice(projectCwd.length);
if (relativePath.startsWith("/")) relativePath = relativePath.slice(1);
}
if (relativePath.startsWith(sfPrefix)) {
root = "sf";
relativePath = relativePath.slice(sfPrefix.length);
}
const key = tabKey(root, relativePath);
// Capture old content before re-fetching (for diff)
const existingTab = openTabs.find((t) => t.key === key);
const oldContent = existingTab?.content ?? null;
// Fetch new content, then store diff
(async () => {
try {
const res = await authFetch(
buildProjectUrl(
`/api/files?root=${root}&path=${encodeURIComponent(relativePath)}`,
projectCwd,
),
);
if (!res.ok) return;
const data = await res.json();
const newContent: string | null = data.content ?? null;
if (newContent !== null) {
const diffData =
oldContent !== null && oldContent !== newContent
? { before: oldContent, after: newContent }
: null;
setOpenTabs((prev) => {
const exists = prev.find((t) => t.key === key);
if (exists) {
return prev.map((t) =>
t.key === key
? {
...t,
content: newContent,
loading: false,
error: null,
diff: diffData,
agentOpened: true,
}
: t,
);
}
// New tab
return [
...prev,
{
key,
root,
path: relativePath,
content: newContent,
loading: false,
error: null,
diff: diffData,
agentOpened: true,
},
];
});
setActiveTabKey(key);
// Auto-clear diff after 8 seconds
if (diffData) {
if (diffTimerRef.current) clearTimeout(diffTimerRef.current);
diffTimerRef.current = setTimeout(() => {
setOpenTabs((prev) =>
prev.map((t) => (t.key === key ? { ...t, diff: null } : t)),
);
}, 8000);
}
}
} catch {
/* ignore */
}
})();
}
}, [completedTools, projectCwd, openTabs]);
// While a file-modifying tool is active, show which file is being worked on
const _activeEditFile = useMemo(() => {
if (!activeToolExec) return null;
if (activeToolExec.name !== "edit" && activeToolExec.name !== "write")
return null;
return typeof activeToolExec.args?.path === "string"
? activeToolExec.args.path
: null;
}, [activeToolExec]);
return (
<div className="flex h-full">
{/* Left panel (file tree or agent chat) */}
<div
className="flex-shrink-0 border-r border-border overflow-hidden flex flex-col"
style={{ width: treeWidth }}
>
{/* Tab bar */}
<div className="flex border-b border-border flex-shrink-0">
<button
type="button"
onClick={() => {
setLeftPanel("tree");
handleTreeRootChange("sf");
}}
className={cn(
"flex-1 px-3 py-2 text-xs font-medium transition-colors",
leftPanel === "tree" && activeRoot === "sf"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
SF
</button>
<button
type="button"
onClick={() => {
setLeftPanel("tree");
handleTreeRootChange("project");
}}
className={cn(
"flex-1 px-3 py-2 text-xs font-medium transition-colors",
leftPanel === "tree" && activeRoot === "project"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Project
</button>
<button
type="button"
onClick={() => setLeftPanel("agent")}
className={cn(
"flex-1 px-3 py-2 text-xs font-medium transition-colors flex items-center justify-center gap-1.5",
leftPanel === "agent"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Bot className="h-3 w-3" />
Agent
</button>
</div>
{/* Panel content */}
{leftPanel === "agent" ? (
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
<ChatPane className="flex-1 min-h-0" />
</div>
) : (
/* Tree content */
<div
className={cn(
"flex-1 overflow-y-auto py-2",
treeRootDragOver && "bg-accent/30",
)}
onDragOver={(e) => {
// Only highlight if dragging directly over the root area, not a folder
if ((e.target as HTMLElement).closest("[data-tree-item]")) return;
if (!e.dataTransfer.types.includes("text/x-tree-path")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setTreeRootDragOver(true);
}}
onDragLeave={(e) => {
// Only clear if leaving the root container entirely
if (
!(e.currentTarget as HTMLElement).contains(
e.relatedTarget as Node,
)
) {
setTreeRootDragOver(false);
}
}}
onDrop={(e) => {
setTreeRootDragOver(false);
if ((e.target as HTMLElement).closest("[data-tree-item]")) return;
e.preventDefault();
const srcPath = e.dataTransfer.getData("text/x-tree-path");
if (!srcPath) return;
// Already at root level?
if (!srcPath.includes("/")) return;
handleMoveFile(srcPath, "");
}}
onContextMenu={(e) => {
// Right-click on empty space in tree — offer New File/Folder at root
if ((e.target as HTMLElement).closest("[data-tree-item]")) return;
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
path: "",
type: "directory",
parentPath: "",
});
}}
>
{loading && !treeLoaded ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading
</div>
) : error && !treeLoaded ? (
<div className="flex items-center justify-center py-8 text-destructive text-xs px-3">
<AlertCircle className="h-4 w-4 mr-2 shrink-0" />
{error}
</div>
) : tree && tree.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground text-xs">
{activeRoot === "sf" ? "No .sf/ files found" : "No files found"}
</div>
) : tree ? (
<>
{/* Root-level create input */}
{creatingIn && creatingIn.parentDir === "" && (
<InlineInput
defaultValue={
creatingIn.type === "directory"
? "new-folder"
: "new-file"
}
onCommit={(name) =>
handleCreateCommit("", name, creatingIn.type)
}
onCancel={handleCreateCancel}
depth={0}
icon={
creatingIn.type === "directory" ? (
<Folder className="h-4 w-4 text-muted-foreground" />
) : (
<File className="h-4 w-4 text-muted-foreground" />
)
}
/>
)}
{tree.map((node, i) => (
<FileTreeItem
key={`${activeRoot}-${i}`}
node={node}
depth={0}
parentPath=""
selectedPath={selectedPath}
expandedPaths={expandedPaths}
renamingPath={renamingPath}
creatingIn={creatingIn}
onToggleDir={handleToggleDir}
onSelectFile={handleSelectFile}
onMoveFile={handleMoveFile}
onContextMenu={handleContextMenu}
onRenameCommit={handleRenameCommit}
onRenameCancel={handleRenameCancel}
onCreateCommit={handleCreateCommit}
onCreateCancel={handleCreateCancel}
/>
))}
</>
) : null}
</div>
)}
</div>
{/* Resize drag handle */}
<div className="relative flex items-stretch" style={{ flexShrink: 0 }}>
<div
className="absolute left-[-3px] top-0 bottom-0 w-[7px] cursor-col-resize z-10 hover:bg-muted-foreground/20 transition-colors"
onMouseDown={handleTreeDragStart}
/>
</div>
{/* File content panel */}
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{/* Open file tabs */}
{openTabs.length > 0 && (
<div className="flex border-b border-border flex-shrink-0 overflow-x-auto bg-background">
{openTabs.map((tab) => (
<button
type="button"
key={tab.key}
onClick={() => {
setActiveTabKey(tab.key);
setActiveRoot(tab.root);
}}
className={cn(
"group flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-r border-border transition-colors shrink-0 max-w-[180px]",
tab.key === activeTabKey
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/20",
)}
>
<span className="truncate" title={tabDisplayPath(tab)}>
{tabLabel(tab)}
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => closeTab(tab.key, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
closeTab(tab.key);
}
}}
className="ml-0.5 rounded p-0.5 opacity-0 group-hover:opacity-100 hover:bg-accent transition-opacity"
>
<X className="h-3 w-3" />
</span>
</button>
))}
</div>
)}
{/* Active tab content */}
{activeTab ? (
activeTab.loading ? (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading
</div>
) : activeTab.error ? (
<div className="flex flex-1 items-center justify-center text-destructive">
<AlertCircle className="h-4 w-4 mr-2" />
{activeTab.error}
</div>
) : activeTab.content !== null ? (
<FileContentViewer
content={activeTab.content}
filepath={tabDisplayPath(activeTab)}
root={activeTab.root}
path={activeTab.path}
onSave={handleSave}
diff={activeTab.diff ?? undefined}
agentOpened={activeTab.agentOpened}
onDismissDiff={() => {
setOpenTabs((prev) =>
prev.map((t) =>
t.key === activeTab.key
? { ...t, diff: null, agentOpened: false }
: t,
),
);
}}
/>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground italic">
No preview available
</div>
)
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Select a file to view
</div>
)}
</div>
{/* Context menu */}
{contextMenu && (
<TreeContextMenu
menu={contextMenu}
onClose={handleContextMenuClose}
onNewFile={handleNewFile}
onNewFolder={handleNewFolder}
onRename={handleRenameStart}
onDelete={handleDelete}
onCopyPath={handleCopyPath}
onDuplicate={handleDuplicate}
/>
)}
{/* Delete confirmation dialog */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 animate-in fade-in-0">
<div className="w-full max-w-sm rounded-lg border border-border bg-popover p-4 shadow-lg animate-in zoom-in-95">
<h3 className="text-sm font-medium text-popover-foreground">
Delete {deleteConfirm.type === "directory" ? "folder" : "file"}?
</h3>
<p className="mt-2 text-xs text-muted-foreground">
Are you sure you want to delete{" "}
<span className="font-mono font-medium text-popover-foreground">
{deleteConfirm.path.split("/").pop()}
</span>
?
{deleteConfirm.type === "directory" &&
" This will delete all contents."}{" "}
This cannot be undone.
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={handleDeleteCancel}
className="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteConfirm}
className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}