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 --- README.md | 2 + src/main/index.ts | 100 ++++++++- src/mcp/lattex.mjs | 196 ++++++++++++++++++ src/preload/index.ts | 14 +- src/renderer/src/App.css | 50 +++++ src/renderer/src/components/Editor.tsx | 26 +++ src/renderer/src/components/FileTree.tsx | 337 ++++++++++++++++++++++++++++--- 7 files changed, 689 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index bd9f82e..046433b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Claude Code can edit `.tex` files directly — changes sync to Overleaf in real- | `get_compile_errors` | Get parsed errors from last compile | | `get_compile_warnings` | Get parsed warnings from last compile | | `get_compile_log` | Get raw compile log output | +| `search_citation` | Search papers on Semantic Scholar, returns BibTeX | +| `search_openalex` | Search works on OpenAlex; BibTeX cross-checked against Semantic Scholar (unverified entries are flagged) | ### Example Workflow diff --git a/src/main/index.ts b/src/main/index.ts index 32be5b3..4f23762 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,8 +2,9 @@ // Licensed under AGPL-3.0 - see LICENSE file import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' -import { join, basename, relative, extname } from 'path' -import { copyFile, readFile, writeFile, mkdir as mkdirAsync, unlink, readdir, stat } from 'fs/promises' +import { join, basename, dirname, relative, extname } from 'path' +import { copyFile, readFile, writeFile, mkdir as mkdirAsync, unlink, readdir, stat, rename as fsRename, rm, cp } from 'fs/promises' +import { existsSync } from 'fs' import { spawn } from 'child_process' import * as pty from 'node-pty' import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' @@ -160,6 +161,82 @@ ipcMain.handle('fs:readBinary', async (_e, filePath: string) => { return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) }) +// ── Workspace file operations (agent scratch space browser) ───── + +ipcMain.handle('fs:writeFile', async (_e, filePath: string, content: string) => { + await mkdirAsync(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, 'utf-8') +}) + +interface DiskNode { + name: string + path: string + isDir: boolean + children?: DiskNode[] +} + +// List a directory tree from disk. Paths in the result are `pathPrefix` + +// path relative to rootPath (so the renderer can key tabs consistently). +ipcMain.handle('fs:listDirTree', async (_e, rootPath: string, pathPrefix: string) => { + const MAX_ENTRIES_PER_DIR = 500 + const MAX_DEPTH = 10 + + async function walk(dir: string, rel: string, depth: number): Promise { + if (depth > MAX_DEPTH) return [] + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + entries = entries + .filter((e) => !e.name.startsWith('.')) + .sort((a, b) => + (b.isDirectory() ? 1 : 0) - (a.isDirectory() ? 1 : 0) || a.name.localeCompare(b.name) + ) + .slice(0, MAX_ENTRIES_PER_DIR) + + const nodes: DiskNode[] = [] + for (const entry of entries) { + const relPath = rel ? `${rel}/${entry.name}` : entry.name + if (entry.isDirectory()) { + nodes.push({ + name: entry.name, + path: pathPrefix + relPath, + isDir: true, + children: await walk(join(dir, entry.name), relPath, depth + 1) + }) + } else if (entry.isFile()) { + nodes.push({ name: entry.name, path: pathPrefix + relPath, isDir: false }) + } + } + return nodes + } + + return walk(rootPath, '', 0) +}) + +ipcMain.handle('fs:mkdirp', async (_e, dirPath: string) => { + await mkdirAsync(dirPath, { recursive: true }) +}) + +ipcMain.handle('fs:rename', async (_e, oldPath: string, newPath: string) => { + await fsRename(oldPath, newPath) +}) + +ipcMain.handle('fs:deletePath', async (_e, targetPath: string) => { + await rm(targetPath, { recursive: true, force: true }) +}) + +ipcMain.handle('fs:copyPath', async (_e, src: string, dest: string) => { + await mkdirAsync(dirname(dest), { recursive: true }) + await cp(src, dest, { recursive: true }) +}) + +ipcMain.handle('fs:exists', async (_e, targetPath: string) => { + return existsSync(targetPath) +}) + // ── API Key Storage ───────────────────────────────────────────── const apiKeysPath = join(app.getPath('userData'), 'api-keys.json') @@ -1031,7 +1108,8 @@ You have MCP tools to interact with Overleaf. Use them proactively. - **read_compiled_pdf**: Get the path to the compiled PDF. After calling this, use your **Read** tool on the returned path to visually inspect the PDF. Use the \`pages\` parameter (e.g. \`"1-3"\`) to read specific pages. This lets you verify formatting, figures, tables, and layout. ### Bibliography -- **search_citation**: Search academic papers by title, topic, or author. Returns matching papers with ready-to-use BibTeX entries that can be pasted directly into a \`.bib\` file. **Note:** Without a Semantic Scholar API key configured in LatteX settings, requests will likely be rate-limited (HTTP 429). With a key, the rate limit is 1 request/second. +- **search_citation**: Search academic papers by title, topic, or author (Semantic Scholar). Returns matching papers with ready-to-use BibTeX entries that can be pasted directly into a \`.bib\` file. **Note:** Without a Semantic Scholar API key configured in LatteX settings, requests will likely be rate-limited (HTTP 429). With a key, the rate limit is 1 request/second. +- **search_openalex**: Search scholarly works via OpenAlex (broader/faster-moving coverage, citation counts, venues). **Citation policy:** OpenAlex metadata lags, so BibTeX is cross-checked against Semantic Scholar — only entries marked "Semantic Scholar ✓" are authoritative. Entries marked "OpenAlex only ⚠" must be verified with a web search (publisher page / arXiv) before citing; very recent papers may be missing from both indexes. ### Workflows @@ -1050,10 +1128,11 @@ You have MCP tools to interact with Overleaf. Use them proactively. 5. To check visual output: use \`read_compiled_pdf\`, then Read the returned path with \`pages: "1-3"\` #### Bibliography Workflow -1. Use \`search_citation\` with a topic or paper title to find references -2. Copy the BibTeX entry into the \`.bib\` file -3. Use \`\\cite{key}\` in the \`.tex\` file -4. Compile to verify the citation renders correctly +1. Use \`search_citation\` (Semantic Scholar) or \`search_openalex\` (broader coverage) to find references +2. If the entry is marked "OpenAlex only ⚠", verify it with a web search before using it +3. Copy the BibTeX entry into the \`.bib\` file +4. Use \`\\cite{key}\` in the \`.tex\` file +5. Compile to verify the citation renders correctly ## Workspace @@ -1095,7 +1174,8 @@ The tools above come from LatteX's MCP server (standard stdio MCP — works with 'mcp__lattex__get_compile_warnings', 'mcp__lattex__get_compile_log', 'mcp__lattex__read_compiled_pdf', - 'mcp__lattex__search_citation' + 'mcp__lattex__search_citation', + 'mcp__lattex__search_openalex' ] } }, null, 2)) @@ -2026,6 +2106,10 @@ ipcMain.handle('shell:openExternal', async (_e, url: string) => { await shell.openExternal(url) }) +ipcMain.handle('shell:openPath', async (_e, targetPath: string) => { + return shell.openPath(targetPath) +}) + ipcMain.handle('shell:showInFinder', async (_e, path: string) => { shell.showItemInFolder(path) }) diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs index e0cb6cd..265ef09 100644 --- a/src/mcp/lattex.mjs +++ b/src/mcp/lattex.mjs @@ -544,6 +544,24 @@ const TOOLS = [ }, required: ['query'] } + }, + { + name: 'search_openalex', + description: 'Search scholarly works via OpenAlex (broad coverage: venues, citation counts, open access). IMPORTANT citation policy: OpenAlex metadata lags and can be noisy, so each result\'s BibTeX is cross-checked against Semantic Scholar — entries marked "Semantic Scholar ✓" are authoritative and safe to paste into a .bib file; entries marked "OpenAlex only ⚠" were NOT found in Semantic Scholar (common for very recent papers) and MUST be verified with a web search (publisher page / arXiv) before citing. Use search_citation for direct Semantic Scholar search.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query — paper title, topic, or keywords' + }, + limit: { + type: 'number', + description: 'Max number of results. Default: 5, max: 8.' + } + }, + required: ['query'] + } } ] @@ -594,6 +612,109 @@ function semanticScholarSearch(query, limit) { }) } +// ── OpenAlex helpers ───────────────────────────────────────── +// +// OpenAlex has broad, fast-moving coverage but its metadata lags behind +// publishers. Policy (see the search_openalex tool description): BibTeX is +// taken from Semantic Scholar when the work exists there; otherwise the +// entry is built from OpenAlex data and explicitly flagged as unverified. + +function openAlexSearch(query, limit) { + return new Promise((resolve) => { + const params = new URLSearchParams({ + search: query, + per_page: String(limit), + // polite pool — OpenAlex asks for a contact address + mailto: 'lattex-app@users.noreply.github.com' + }) + const options = { + hostname: 'api.openalex.org', + path: `/works?${params}`, + method: 'GET', + headers: { 'User-Agent': 'LatteX-MCP/1.0' } + } + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + try { + const parsed = JSON.parse(data) + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ ok: true, works: parsed.results || [] }) + } else { + resolve({ ok: false, error: `HTTP ${res.statusCode}: ${(parsed.message || data).slice(0, 200)}` }) + } + } catch { + resolve({ ok: false, error: `Failed to parse response: ${data.slice(0, 200)}` }) + } + }) + }) + req.on('error', (e) => resolve({ ok: false, error: e.message })) + req.end() + }) +} + +/** OpenAlex stores abstracts as an inverted index — rebuild the text */ +function reconstructAbstract(invertedIndex) { + if (!invertedIndex) return null + const words = [] + for (const [word, positions] of Object.entries(invertedIndex)) { + for (const pos of positions) words[pos] = word + } + return words.filter(Boolean).join(' ') +} + +/** Map an OpenAlex work onto the paper shape used by paperToBibtex */ +function openAlexWorkToPaper(work) { + const doi = (work.doi || '').replace(/^https?:\/\/doi\.org\//i, '') || undefined + const arxivId = work.ids?.arxiv?.replace(/^https?:\/\/arxiv\.org\/abs\//i, '') || undefined + return { + title: work.display_name || '', + authors: (work.authorships || []).map((a) => ({ name: a.author?.display_name || '' })), + year: work.publication_year, + venue: work.primary_location?.source?.display_name || '', + citationCount: work.cited_by_count, + abstract: reconstructAbstract(work.abstract_inverted_index), + externalIds: { DOI: doi, ArXiv: arxivId } + } +} + +/** Look up a paper on Semantic Scholar by DOI (authoritative BibTeX source) */ +function s2LookupByDoi(doi) { + return new Promise((resolve) => { + const fields = 'title,authors,year,externalIds,venue,citationCount,citationStyles' + const headers = { 'User-Agent': 'LatteX-MCP/1.0' } + const apiKey = getSemanticScholarApiKey() + if (apiKey) headers['x-api-key'] = apiKey + const options = { + hostname: 'api.semanticscholar.org', + path: `/graph/v1/paper/DOI:${encodeURIComponent(doi)}?fields=${fields}`, + method: 'GET', + headers + } + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + try { + const parsed = JSON.parse(data) + if (res.statusCode >= 200 && res.statusCode < 300 && parsed.paperId) { + resolve({ ok: true, paper: parsed }) + } else if (res.statusCode === 429) { + resolve({ ok: false, rateLimited: true }) + } else { + resolve({ ok: false }) + } + } catch { + resolve({ ok: false }) + } + }) + }) + req.on('error', () => resolve({ ok: false })) + req.end() + }) +} + /** Generate a BibTeX key from author surname + year + first title word */ function makeBibtexKey(paper) { let authorPart = 'unknown' @@ -1094,6 +1215,81 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ) } + case 'search_openalex': { + const query = args.query + const limit = Math.min(args?.limit || 5, 8) + + const searchResult = await openAlexSearch(query, limit) + if (!searchResult.ok) { + return errorResult(`OpenAlex search failed: ${searchResult.error}`) + } + if (searchResult.works.length === 0) { + return textResult(`No works found on OpenAlex for query: "${query}"`) + } + + let s2RateLimited = false + const entries = [] + for (let i = 0; i < searchResult.works.length; i++) { + const paper = openAlexWorkToPaper(searchResult.works[i]) + const doi = paper.externalIds.DOI + + // Cross-check against Semantic Scholar — its BibTeX is authoritative + let bibtex = null + let source = null + if (doi && !s2RateLimited) { + const s2 = await s2LookupByDoi(doi) + if (s2.ok) { + bibtex = s2.paper.citationStyles?.bibtex?.trim() || paperToBibtex(s2.paper) + source = 'BibTeX source: Semantic Scholar ✓ (authoritative)' + } else if (s2.rateLimited) { + s2RateLimited = true + } + // be polite to the shared unauthenticated pool + if (!getSemanticScholarApiKey()) await new Promise(r => setTimeout(r, 350)) + } + // OpenAlex DOIs can point at mirrors/reprints — if the DOI lookup + // missed, try an exact-title match on Semantic Scholar instead + if (!bibtex && !s2RateLimited && paper.title) { + const titleSearch = await semanticScholarSearch(paper.title, 3) + if (titleSearch.ok) { + const match = titleSearch.papers.find( + (p) => (p.title || '').trim().toLowerCase() === paper.title.trim().toLowerCase() + ) + if (match) { + bibtex = paperToBibtex(match) + source = 'BibTeX source: Semantic Scholar ✓ (matched by title — OpenAlex DOI may point at a mirror)' + } + } else if (/429/.test(titleSearch.error || '')) { + s2RateLimited = true + } + if (!getSemanticScholarApiKey()) await new Promise(r => setTimeout(r, 350)) + } + if (!bibtex) { + bibtex = paperToBibtex(paper) + source = s2RateLimited && doi + ? 'BibTeX source: OpenAlex only ⚠ (Semantic Scholar rate-limited — retry later or add an API key in LatteX settings; verify before citing)' + : 'BibTeX source: OpenAlex only ⚠ (not found in Semantic Scholar — possibly too recent; VERIFY via web search / publisher page before citing)' + } + + const cited = paper.citationCount != null ? ` (cited ${paper.citationCount}×)` : '' + const abs = paper.abstract + ? `\n${paper.abstract.length > 300 ? paper.abstract.slice(0, 300) + '...' : paper.abstract}\n` + : '' + entries.push( + `### ${i + 1}. ${paper.title}${cited}\n` + + `${paper.authors.map(a => a.name).filter(Boolean).join(', ') || 'Unknown authors'} — ${paper.venue || 'Unknown venue'}, ${paper.year || '?'}${doi ? `\nDOI: ${doi}` : ''}\n` + + `${source}\n${abs}\n\`\`\`bibtex\n${bibtex}\n\`\`\`` + ) + } + + return textResult( + `Found ${entries.length} work(s) on OpenAlex for "${query}".\n` + + `Citation policy: entries marked "Semantic Scholar ✓" are safe to paste into your .bib file; ` + + `entries marked "OpenAlex only ⚠" must be verified with a web search before citing (OpenAlex lags and very recent papers may be missing from both indexes).\n\n` + + entries.join('\n\n') + ) + } + default: return errorResult(`Unknown tool: ${name}`) } diff --git a/src/preload/index.ts b/src/preload/index.ts index d3cddea..a4d8179 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,8 +12,20 @@ window.addEventListener('wheel', (e) => { const api = { // File system - readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path), + readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path) as Promise, readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise, + writeFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content) as Promise, + listDirTree: (rootPath: string, pathPrefix: string) => + ipcRenderer.invoke('fs:listDirTree', rootPath, pathPrefix) as Promise + }>>, + mkdirp: (path: string) => ipcRenderer.invoke('fs:mkdirp', path) as Promise, + renamePath: (oldPath: string, newPath: string) => ipcRenderer.invoke('fs:rename', oldPath, newPath) as Promise, + deletePath: (path: string) => ipcRenderer.invoke('fs:deletePath', path) as Promise, + copyPath: (src: string, dest: string) => ipcRenderer.invoke('fs:copyPath', src, dest) as Promise, + pathExists: (path: string) => ipcRenderer.invoke('fs:exists', path) as Promise, + openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path) as Promise, // LaTeX onCompileLog: (cb: (log: string) => void) => { 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