summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-07-30 08:53:27 +0800
committerhaoyuren <13851610112@163.com>2026-07-30 08:53:27 +0800
commitc9cd5119077c69088eb8ba768ee1e1602baec751 (patch)
tree843842b3c7676b66672db3893ee25a89b47830b6 /src/renderer
parentd35a6a0ef366f86aeba28c06f40e44cd29cf1f07 (diff)
Add workspace file browser and OpenAlex citation search
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 <noreply@anthropic.com>
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/src/App.css50
-rw-r--r--src/renderer/src/components/Editor.tsx26
-rw-r--r--src/renderer/src/components/FileTree.tsx337
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>
)
}