diff options
Diffstat (limited to 'src/renderer')
| -rw-r--r-- | src/renderer/src/App.css | 50 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 26 | ||||
| -rw-r--r-- | src/renderer/src/components/FileTree.tsx | 337 |
3 files changed, 386 insertions, 27 deletions
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 0db81f8..69f0139 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -486,6 +486,56 @@ html, body, #root { border-bottom: 1px solid var(--border); } +/* Project / Workspace view toggle */ +.file-tree-tabs { + padding: 6px 8px; + gap: 4px; + justify-content: flex-start; +} +.file-tree-tab { + flex: 1; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); + background: none; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-family: var(--font-sans); +} +.file-tree-tab:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.file-tree-tab.active { + background: var(--bg-active); + color: var(--text-primary); +} + +.file-tree-ws-toolbar { + display: flex; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); +} +.file-tree-ws-btn { + padding: 3px 8px; + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: none; + border-radius: var(--radius); + cursor: pointer; + font-family: var(--font-sans); +} +.file-tree-ws-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + .file-tree-action { width: 22px; height: 22px; diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 2afdc0e..14373d1 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -88,6 +88,7 @@ export default function Editor() { const docSyncRef = useRef<OverleafDocSync | null>(null) const cursorThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const wsSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const [editorFontSize, setEditorFontSize] = useState(13.5) // Add comment state @@ -181,6 +182,19 @@ export default function Editor() { const docId = pathDocMap[activeTab] if (docId) { window.api.syncContentChanged(docId, newContent) + } else if (activeTab.startsWith('claude-workspace/')) { + // Workspace files live only on disk — debounced auto-save + const dir = useAppStore.getState().syncDir + if (dir) { + if (wsSaveTimerRef.current) clearTimeout(wsSaveTimerRef.current) + const tab = activeTab + wsSaveTimerRef.current = setTimeout(() => { + wsSaveTimerRef.current = null + window.api.writeFile(`${dir}/${tab}`, newContent) + .then(() => useAppStore.getState().markModified(tab, false)) + .catch(() => useAppStore.getState().setStatusMessage(`Failed to save ${tab}`)) + }, 500) + } } } if (update.selectionSet) { @@ -301,6 +315,18 @@ export default function Editor() { } return () => { + // Flush a pending workspace save so switching tabs never loses edits + if (wsSaveTimerRef.current && activeTab?.startsWith('claude-workspace/')) { + clearTimeout(wsSaveTimerRef.current) + wsSaveTimerRef.current = null + const dir = useAppStore.getState().syncDir + const content = useAppStore.getState().fileContents[activeTab] + if (dir && content !== undefined) { + window.api.writeFile(`${dir}/${activeTab}`, content) + .then(() => useAppStore.getState().markModified(activeTab, false)) + .catch(() => {}) + } + } if (docSyncRef.current) { const docId = pathDocMap[activeTab!] if (docId) { diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index 1b9ce5e..5486f20 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -4,10 +4,24 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { useAppStore, type FileNode } from '../stores/appStore' +const BINARY_EXTS = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'eps', 'zip', 'tiff', 'bmp']) + interface ContextMenuState { x: number y: number node: FileNode + view: 'project' | 'workspace' +} + +function fileIcon(node: FileNode, expanded: boolean): string { + if (node.isDir) return expanded ? '📂' : '📁' + const ext = node.name.split('.').pop()?.toLowerCase() ?? '' + return ext === 'tex' ? '📄' + : ext === 'bib' ? '📚' + : ext === 'pdf' ? '📕' + : ext === 'png' || ext === 'jpg' || ext === 'jpeg' ? '🖼️' + : ext === 'py' || ext === 'sh' ? '⚙️' + : '📝' } function FileTreeNode({ @@ -20,7 +34,7 @@ function FileTreeNode({ onContextMenu: (e: React.MouseEvent, node: FileNode) => void }) { const [expanded, setExpanded] = useState(depth < 2) - const { activeTab, openFile, setFileContent, setStatusMessage, mainDocument, docPathMap } = useAppStore() + const { activeTab, openFile, setFileContent, setStatusMessage, mainDocument } = useAppStore() const isActive = activeTab === node.path const isMainDoc = node.docId && mainDocument === node.docId @@ -70,15 +84,6 @@ function FileTreeNode({ } }, [node, expanded, openFile, setFileContent, setStatusMessage]) - const ext = node.name.split('.').pop()?.toLowerCase() ?? '' - const icon = node.isDir - ? expanded ? '📂' : '📁' - : ext === 'tex' ? '📄' - : ext === 'bib' ? '📚' - : ext === 'pdf' ? '📕' - : ext === 'png' || ext === 'jpg' ? '🖼️' - : '📝' - return ( <div> <div @@ -87,7 +92,7 @@ function FileTreeNode({ onClick={handleClick} onContextMenu={(e) => onContextMenu(e, node)} > - <span className="file-icon">{icon}</span> + <span className="file-icon">{fileIcon(node, expanded)}</span> <span className="file-name"> {node.name} {isMainDoc && <span className="main-doc-badge">main</span>} @@ -100,11 +105,84 @@ function FileTreeNode({ ) } +/** Workspace (agent scratch space) node — files live on disk, not on Overleaf */ +function WorkspaceNode({ + node, + depth, + onContextMenu +}: { + node: FileNode + depth: number + onContextMenu: (e: React.MouseEvent, node: FileNode) => void +}) { + const [expanded, setExpanded] = useState(depth < 2) + const { activeTab, openFile, setFileContent, setStatusMessage, syncDir } = useAppStore() + const isActive = activeTab === node.path + + const handleClick = useCallback(async () => { + if (node.isDir) { + setExpanded(!expanded) + return + } + const abs = `${syncDir}/${node.path}` + const ext = node.name.split('.').pop()?.toLowerCase() ?? '' + if (BINARY_EXTS.has(ext)) { + // Open binaries with the system default app + window.api.openPath(abs) + return + } + try { + const content = await window.api.readFile(abs) + setFileContent(node.path, content) + openFile(node.path, node.name) + } catch { + setStatusMessage(`Failed to open ${node.name}`) + } + }, [node, expanded, syncDir, openFile, setFileContent, setStatusMessage]) + + return ( + <div> + <div + className={`file-tree-item ${isActive ? 'active' : ''}`} + style={{ paddingLeft: depth * 16 + 8 }} + onClick={handleClick} + onContextMenu={(e) => onContextMenu(e, node)} + > + <span className="file-icon">{fileIcon(node, expanded)}</span> + <span className="file-name">{node.name}</span> + </div> + {node.isDir && expanded && node.children?.map((child) => ( + <WorkspaceNode key={child.path} node={child} depth={depth + 1} onContextMenu={onContextMenu} /> + ))} + </div> + ) +} + export default function FileTree() { - const { files } = useAppStore() + const { files, syncDir } = useAppStore() + const [view, setView] = useState<'project' | 'workspace'>('project') + const [wsNodes, setWsNodes] = useState<FileNode[]>([]) const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null) const menuRef = useRef<HTMLDivElement>(null) + const workspaceRoot = syncDir ? `${syncDir}/claude-workspace` : '' + + const loadWorkspace = useCallback(async () => { + if (!workspaceRoot) return + try { + const nodes = await window.api.listDirTree(workspaceRoot, 'claude-workspace/') + setWsNodes(nodes as FileNode[]) + } catch { /* workspace may not exist yet */ } + }, [workspaceRoot]) + + // Refresh workspace on switch + poll while visible (agents write here) + useEffect(() => { + if (view !== 'workspace') return + loadWorkspace() + const timer = setInterval(loadWorkspace, 5000) + return () => clearInterval(timer) + }, [view, loadWorkspace]) + // Close context menu on outside click or escape useEffect(() => { if (!ctxMenu) return @@ -127,11 +205,13 @@ export default function FileTree() { const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode) => { e.preventDefault() e.stopPropagation() - setCtxMenu({ x: e.clientX, y: e.clientY, node }) - }, []) + setCtxMenu({ x: e.clientX, y: e.clientY, node, view }) + }, [view]) const closeMenu = () => setCtxMenu(null) + // ── Project view actions (Overleaf entities) ── + const handleSetMainDoc = () => { if (!ctxMenu) return const node = ctxMenu.node @@ -258,6 +338,103 @@ export default function FileTree() { closeMenu() } + // ── Workspace view actions (disk files under claude-workspace/) ── + + const wsAbs = (node: FileNode) => `${syncDir}/${node.path}` + + /** Copy a workspace file/folder into the project root — the sync bridge + detects it and creates it on Overleaf. */ + const handleImportToProject = async () => { + if (!ctxMenu || !syncDir) return + const node = ctxMenu.node + closeMenu() + + // Avoid clobbering an existing project file with the same name + let destName = node.name + if (await window.api.pathExists(`${syncDir}/${destName}`)) { + const dot = destName.lastIndexOf('.') + const stem = dot > 0 ? destName.slice(0, dot) : destName + const ext = dot > 0 ? destName.slice(dot) : '' + destName = `${stem}-imported${ext}` + if (await window.api.pathExists(`${syncDir}/${destName}`)) { + useAppStore.getState().setStatusMessage(`Import failed: ${destName} already exists`) + return + } + } + + try { + await window.api.copyPath(wsAbs(node), `${syncDir}/${destName}`) + useAppStore.getState().setStatusMessage(`Imported ${destName} — syncing to Overleaf`) + } catch (e) { + useAppStore.getState().setStatusMessage(`Import failed: ${e}`) + } + } + + const handleWsRename = async () => { + if (!ctxMenu || !syncDir) return + const node = ctxMenu.node + const newName = prompt('New name:', node.name) + closeMenu() + if (!newName?.trim() || newName === node.name) return + const parent = node.path.slice(0, node.path.lastIndexOf('/')) + try { + await window.api.renamePath(wsAbs(node), `${syncDir}/${parent}/${newName.trim()}`) + loadWorkspace() + } catch (e) { + useAppStore.getState().setStatusMessage(`Rename failed: ${e}`) + } + } + + const handleWsDelete = async () => { + if (!ctxMenu || !syncDir) return + const node = ctxMenu.node + closeMenu() + if (!confirm(`Delete "${node.name}" from workspace?`)) return + try { + await window.api.deletePath(wsAbs(node)) + useAppStore.getState().closeTab(node.path) + loadWorkspace() + } catch (e) { + useAppStore.getState().setStatusMessage(`Delete failed: ${e}`) + } + } + + const handleWsNewFile = async (parentNode?: FileNode) => { + if (!syncDir) return + closeMenu() + const name = prompt('New file name:', 'notes.md') + if (!name?.trim()) return + const parentPath = parentNode?.isDir ? parentNode.path : 'claude-workspace' + try { + await window.api.writeFile(`${syncDir}/${parentPath}/${name.trim()}`, '') + loadWorkspace() + } catch (e) { + useAppStore.getState().setStatusMessage(`Create failed: ${e}`) + } + } + + const handleWsNewFolder = async (parentNode?: FileNode) => { + if (!syncDir) return + closeMenu() + const name = prompt('New folder name:', 'new-folder') + if (!name?.trim()) return + const parentPath = parentNode?.isDir ? parentNode.path : 'claude-workspace' + try { + await window.api.mkdirp(`${syncDir}/${parentPath}/${name.trim()}`) + loadWorkspace() + } catch (e) { + useAppStore.getState().setStatusMessage(`Create failed: ${e}`) + } + } + + const handleRevealInFinder = () => { + if (!ctxMenu || !syncDir) return + window.api.showInFinder(wsAbs(ctxMenu.node)) + closeMenu() + } + + // ── Drag & drop ── + const [dragOver, setDragOver] = useState(false) const handleDragOver = useCallback((e: React.DragEvent) => { @@ -277,13 +454,32 @@ export default function FileTree() { e.stopPropagation() setDragOver(false) + const droppedFiles = e.dataTransfer.files + if (droppedFiles.length === 0) return + + if (view === 'workspace') { + // Copy into the agent workspace on disk (not synced to Overleaf) + const dir = useAppStore.getState().syncDir + if (!dir) return + for (let i = 0; i < droppedFiles.length; i++) { + const file = droppedFiles[i] + const srcPath = window.api.getPathForFile(file) + try { + await window.api.copyPath(srcPath, `${dir}/claude-workspace/${file.name}`) + useAppStore.getState().setStatusMessage(`Copied ${file.name} to workspace`) + } catch (err) { + useAppStore.getState().setStatusMessage(`Copy failed: ${err}`) + return + } + } + loadWorkspace() + return + } + const projectId = useAppStore.getState().overleafProjectId const folderId = useAppStore.getState().rootFolderId if (!projectId || !folderId) return - const droppedFiles = e.dataTransfer.files - if (droppedFiles.length === 0) return - for (let i = 0; i < droppedFiles.length; i++) { const file = droppedFiles[i] const filePath = window.api.getPathForFile(file) @@ -298,8 +494,7 @@ export default function FileTree() { return } } - - }, []) + }, [view, loadWorkspace]) const handleOpenInOverleaf = () => { const projectId = useAppStore.getState().overleafProjectId @@ -316,19 +511,64 @@ export default function FileTree() { onDragLeave={handleDragLeave} onDrop={handleDrop} > - <div className="file-tree-header"> - <span>FILES</span> + <div className="file-tree-header file-tree-tabs"> + <button + className={`file-tree-tab ${view === 'project' ? 'active' : ''}`} + onClick={() => setView('project')} + title="Overleaf project files (synced)" + > + Project + </button> + <button + className={`file-tree-tab ${view === 'workspace' ? 'active' : ''}`} + onClick={() => setView('workspace')} + title="Agent scratch space (claude-workspace/, not synced to Overleaf)" + > + Workspace + </button> </div> + + {view === 'workspace' && ( + <div className="file-tree-ws-toolbar"> + <button className="file-tree-ws-btn" title="New file" onClick={() => handleWsNewFile()}>+ File</button> + <button className="file-tree-ws-btn" title="New folder" onClick={() => handleWsNewFolder()}>+ Folder</button> + <button className="file-tree-ws-btn" title="Refresh" onClick={loadWorkspace}>↻</button> + <button + className="file-tree-ws-btn" + title="Reveal workspace in Finder" + onClick={() => workspaceRoot && window.api.showInFinder(workspaceRoot)} + > + Finder + </button> + </div> + )} + <div className="file-tree-content"> - {files.map((node) => ( - <FileTreeNode key={node.path} node={node} depth={0} onContextMenu={handleContextMenu} /> - ))} - {files.length === 0 && ( - <div className="file-tree-empty">No files found</div> + {view === 'project' ? ( + <> + {files.map((node) => ( + <FileTreeNode key={node.path} node={node} depth={0} onContextMenu={handleContextMenu} /> + ))} + {files.length === 0 && ( + <div className="file-tree-empty">No files found</div> + )} + </> + ) : ( + <> + {wsNodes.map((node) => ( + <WorkspaceNode key={node.path} node={node} depth={0} onContextMenu={handleContextMenu} /> + ))} + {wsNodes.length === 0 && ( + <div className="file-tree-empty"> + Workspace is empty — agents use this scratch space for notes, + experiments, and generated files. Drop files here to add them. + </div> + )} + </> )} </div> - {ctxMenu && ( + {ctxMenu && ctxMenu.view === 'project' && ( <div ref={menuRef} className="context-menu" @@ -366,6 +606,49 @@ export default function FileTree() { </div> </div> )} + + {ctxMenu && ctxMenu.view === 'workspace' && ( + <div + ref={menuRef} + className="context-menu" + style={{ left: ctxMenu.x, top: ctxMenu.y }} + > + {!ctxMenu.node.isDir && ( + <div className="context-menu-item" onClick={handleImportToProject}> + Import to Project + </div> + )} + {ctxMenu.node.isDir && ( + <div className="context-menu-item" onClick={handleImportToProject}> + Import Folder to Project + </div> + )} + <div className="context-menu-item" onClick={handleCopyPath}> + Copy Path + </div> + <div className="context-menu-separator" /> + <div className="context-menu-item" onClick={handleWsRename}> + Rename + </div> + {ctxMenu.node.isDir && ( + <> + <div className="context-menu-item" onClick={() => handleWsNewFile(ctxMenu.node)}> + New File + </div> + <div className="context-menu-item" onClick={() => handleWsNewFolder(ctxMenu.node)}> + New Folder + </div> + </> + )} + <div className="context-menu-item" onClick={handleRevealInFinder}> + Reveal in Finder + </div> + <div className="context-menu-separator" /> + <div className="context-menu-item danger" onClick={handleWsDelete}> + Delete + </div> + </div> + )} </div> ) } |
