From c9cd5119077c69088eb8ba768ee1e1602baec751 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Thu, 30 Jul 2026 08:53:27 +0800 Subject: Add workspace file browser and OpenAlex citation search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace browser: - The left panel gains a Project/Workspace toggle; the Workspace view browses the agent scratch space (claude-workspace/, never synced) - Open/edit workspace text files in the editor with debounced auto-save (flushed on tab switch); binaries open with the system default app - Import to Project copies a workspace file/folder into the project root where the sync bridge picks it up and creates it on Overleaf (collision-safe: falls back to a -imported suffix) - New file/folder, rename, delete, reveal in Finder, drag-drop into the workspace, 5s auto-refresh while visible OpenAlex search (MCP): - New search_openalex tool: broad OpenAlex coverage with an explicit citation-authority policy — BibTeX is cross-checked against Semantic Scholar (by DOI, then by exact title, since OpenAlex DOIs can point at mirrors); entries not found on S2 are flagged "OpenAlex only" with instructions to verify via web search before citing - Tool description, agent guide, permission allowlist, and README updated Co-Authored-By: Claude Fable 5 --- src/renderer/src/App.css | 50 +++++ src/renderer/src/components/Editor.tsx | 26 +++ src/renderer/src/components/FileTree.tsx | 337 ++++++++++++++++++++++++++++--- 3 files changed, 386 insertions(+), 27 deletions(-) (limited to 'src/renderer') 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(null) const cursorThrottleRef = useRef | null>(null) + const wsSaveTimerRef = useRef | 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 (
onContextMenu(e, node)} > - {icon} + {fileIcon(node, expanded)} {node.name} {isMainDoc && main} @@ -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 ( +
+
onContextMenu(e, node)} + > + {fileIcon(node, expanded)} + {node.name} +
+ {node.isDir && expanded && node.children?.map((child) => ( + + ))} +
+ ) +} + export default function FileTree() { - const { files } = useAppStore() + const { files, syncDir } = useAppStore() + const [view, setView] = useState<'project' | 'workspace'>('project') + const [wsNodes, setWsNodes] = useState([]) const [ctxMenu, setCtxMenu] = useState(null) const menuRef = useRef(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} > -
- FILES +
+ +
+ + {view === 'workspace' && ( +
+ + + + +
+ )} +
- {files.map((node) => ( - - ))} - {files.length === 0 && ( -
No files found
+ {view === 'project' ? ( + <> + {files.map((node) => ( + + ))} + {files.length === 0 && ( +
No files found
+ )} + + ) : ( + <> + {wsNodes.map((node) => ( + + ))} + {wsNodes.length === 0 && ( +
+ Workspace is empty — agents use this scratch space for notes, + experiments, and generated files. Drop files here to add them. +
+ )} + )}
- {ctxMenu && ( + {ctxMenu && ctxMenu.view === 'project' && (
)} + + {ctxMenu && ctxMenu.view === 'workspace' && ( +
+ {!ctxMenu.node.isDir && ( +
+ Import to Project +
+ )} + {ctxMenu.node.isDir && ( +
+ Import Folder to Project +
+ )} +
+ Copy Path +
+
+
+ Rename +
+ {ctxMenu.node.isDir && ( + <> +
handleWsNewFile(ctxMenu.node)}> + New File +
+
handleWsNewFolder(ctxMenu.node)}> + New Folder +
+ + )} +
+ Reveal in Finder +
+
+
+ Delete +
+
+ )}
) } -- cgit v1.2.3