- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep - Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts - Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests) - Rename log prefix "gsd-db:" → "forge-db:" in template literals - Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds - Update CI workflow artifact names (build-native.yml) - Verify only packages/native/* touched (no upstream pi-* packages renamed) Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is sf-run-specific; all gsd-prefixed logging and crate names must align with new identity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1400 lines
48 KiB
TypeScript
1400 lines
48 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
|
import {
|
|
FileText,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
FolderOpen,
|
|
FileCode,
|
|
File,
|
|
Loader2,
|
|
AlertCircle,
|
|
X,
|
|
FilePlus,
|
|
FolderPlus,
|
|
Pencil,
|
|
Trash2,
|
|
Copy,
|
|
ClipboardCopy,
|
|
Bot,
|
|
} from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { useGSDWorkspaceState, buildProjectUrl } from "@/lib/sf-workspace-store"
|
|
import { authFetch } from "@/lib/auth"
|
|
import { FileContentViewer } from "@/components/sf/file-content-viewer"
|
|
import { ChatPane } from "@/components/sf/chat-mode"
|
|
|
|
type RootMode = "gsd" | "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("gsd: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 `gsd-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
|
|
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
|
|
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 === "gsd" ? `.gsd/${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 = useGSDWorkspaceState()
|
|
const projectCwd = workspace.boot?.project.cwd
|
|
|
|
const [activeRoot, setActiveRoot] = useState<RootMode>("gsd")
|
|
const [leftPanel, setLeftPanel] = useState<LeftPanel>("tree")
|
|
const [gsdTree, setGsdTree] = 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 [gsdExpanded, setGsdExpanded] = useState<Set<string>>(() => loadExpanded(projectCwd, "gsd"))
|
|
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
|
|
setGsdExpanded(loadExpanded(projectCwd, "gsd"))
|
|
setProjectExpanded(loadExpanded(projectCwd, "project"))
|
|
}, [projectCwd])
|
|
|
|
const expandedPaths = activeRoot === "gsd" ? gsdExpanded : projectExpanded
|
|
const setExpandedPaths = activeRoot === "gsd" ? setGsdExpanded : 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 === "gsd" ? gsdTree : projectTree
|
|
const treeLoaded = activeRoot === "gsd" ? gsdTree !== 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 === "gsd") {
|
|
setGsdTree(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) {
|
|
fetchTree(activeRoot)
|
|
}
|
|
}, [activeRoot, treeLoaded, fetchTree])
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
fetchTree("gsd")
|
|
}, [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 === "gsd" ? setGsdExpanded : 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 === "gsd" && !gsdTree) {
|
|
fetchTree("gsd")
|
|
} else if (root === "project" && !projectTree) {
|
|
fetchTree("project")
|
|
}
|
|
|
|
await openFileTab(root, path)
|
|
}, [gsdTree, 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
|
|
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("gsd:open-file", handler)
|
|
return () => window.removeEventListener("gsd: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 === "gsd" ? setGsdExpanded : 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 === "gsd" ? setGsdExpanded : 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, type } = 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 === "gsd" ? `.gsd/${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 (!gsdTree || openTabs.length > 0 || consumedPendingRef.current) return
|
|
const hasStateMd = gsdTree.some((n) => n.name === "STATE.md" && n.type === "file")
|
|
if (hasStateMd) {
|
|
autoSelectedRef.current = true
|
|
void openFileTab("gsd", "STATE.md")
|
|
}
|
|
}, [gsdTree, 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 gsdPrefix = ".gsd/"
|
|
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(gsdPrefix)) {
|
|
root = "gsd"
|
|
relativePath = relativePath.slice(gsdPrefix.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
|
|
onClick={() => { setLeftPanel("tree"); handleTreeRootChange("gsd") }}
|
|
className={cn(
|
|
"flex-1 px-3 py-2 text-xs font-medium transition-colors",
|
|
leftPanel === "tree" && activeRoot === "gsd"
|
|
? "border-b-2 border-foreground text-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
GSD
|
|
</button>
|
|
<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
|
|
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 === "gsd" ? "No .gsd/ 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
|
|
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
|
|
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
|
|
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>
|
|
)
|
|
}
|