summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--src/main/index.ts100
-rw-r--r--src/mcp/lattex.mjs196
-rw-r--r--src/preload/index.ts14
-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
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<DiskNode[]> {
+ 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<string>,
readBinary: (path: string) => ipcRenderer.invoke('fs:readBinary', path) as Promise<ArrayBuffer>,
+ writeFile: (path: string, content: string) => ipcRenderer.invoke('fs:writeFile', path, content) as Promise<void>,
+ listDirTree: (rootPath: string, pathPrefix: string) =>
+ ipcRenderer.invoke('fs:listDirTree', rootPath, pathPrefix) as Promise<Array<{
+ name: string; path: string; isDir: boolean
+ children?: Array<{ name: string; path: string; isDir: boolean; children?: unknown[] }>
+ }>>,
+ mkdirp: (path: string) => ipcRenderer.invoke('fs:mkdirp', path) as Promise<void>,
+ renamePath: (oldPath: string, newPath: string) => ipcRenderer.invoke('fs:rename', oldPath, newPath) as Promise<void>,
+ deletePath: (path: string) => ipcRenderer.invoke('fs:deletePath', path) as Promise<void>,
+ copyPath: (src: string, dest: string) => ipcRenderer.invoke('fs:copyPath', src, dest) as Promise<void>,
+ pathExists: (path: string) => ipcRenderer.invoke('fs:exists', path) as Promise<boolean>,
+ openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path) as Promise<string>,
// 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<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>
)
}