From 04fe34af29c0c05c7b2846a2408d1ff07879ed32 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sun, 24 May 2026 03:49:57 +0200 Subject: [PATCH] feat(ui): per-project .work-files/ navigable tab Adds a sidebar tab next to Plans/Memory listing files under each project's .work-files/ directory, with content view (markdown rendered, JSON pretty-printed, plain text otherwise). IPC handlers list-work-files / read-work-file are path-validated under /.work-files to prevent directory traversal. New jsdom smoke test covers the renderer. --- eslint.config.js | 6 + main.js | 125 ++++++++++++ preload.js | 2 + public/app.js | 18 ++ public/icons.js | 1 + public/index.html | 3 + public/plans-memory-view.js | 138 ++++++++++++++ public/style.css | 36 ++++ test/dom-work-files-view.test.js | 315 +++++++++++++++++++++++++++++++ 9 files changed, 644 insertions(+) create mode 100644 test/dom-work-files-view.test.js diff --git a/eslint.config.js b/eslint.config.js index 49d49df8..3ea234f3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,9 @@ const rendererCrossFileGlobals = { planPanel: 'readonly', memoryViewer: 'readonly', memoryPanel: 'readonly', + workFilesContent: 'readonly', + workFilesViewer: 'readonly', + workFilesPanel: 'readonly', statsViewer: 'readonly', statsViewerBody: 'readonly', settingsViewer: 'readonly', @@ -173,6 +176,9 @@ const rendererCrossFileGlobals = { loadStats: 'readonly', loadMemories: 'readonly', renderMemories: 'readonly', + loadWorkFiles: 'readonly', + renderWorkFiles: 'readonly', + openWorkFile: 'readonly', clearNotifications: 'readonly', setSessionMcpActive: 'readonly', destroySession: 'readonly', diff --git a/main.js b/main.js index c73e9be7..ab3215f0 100644 --- a/main.js +++ b/main.js @@ -891,6 +891,131 @@ ipcMain.handle('save-memory', (_event, filePath, content) => { } }); +// --- IPC: get-work-files --- +// Walks /.work-files/ recursively for all known projects. +// Returns { projects: WorkFilesProject[] } — empty projects are skipped. +// Caps at WORK_FILES_CAP files per project (most recent by mtime) to guard +// against huge .work-files trees (e.g. tagpay has ~39k files). +const WORK_FILES_CAP = 200; + +function walkWorkFiles(dir, baseDir, results) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const fullPath = path.join(dir, e.name); + if (e.isDirectory()) { + walkWorkFiles(fullPath, baseDir, results); + } else if (e.isFile()) { + try { + const stat = fs.statSync(fullPath); + const relativePath = path.relative(baseDir, fullPath); + results.push({ + filename: e.name, + filePath: fullPath, + relativePath, + modified: stat.mtime.toISOString(), + size: stat.size, + }); + } catch {} + } + } +} + +ipcMain.handle('get-work-files', () => { + const global = getSetting('global') || {}; + const hiddenProjects = new Set(global.hiddenProjects || []); + const projects = []; + + try { + if (fs.existsSync(PROJECTS_DIR)) { + const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory() && d.name !== '.git') + .map(d => d.name); + + for (const folder of folders) { + const folderPath = path.join(PROJECTS_DIR, folder); + const projectPath = deriveProjectPath(folderPath, folder); + if (!projectPath) continue; + if (hiddenProjects.has(projectPath)) continue; + + const workFilesDir = path.join(projectPath, '.work-files'); + if (!fs.existsSync(workFilesDir)) continue; + + const shortName = projectPath.split('/').filter(Boolean).slice(-2).join('/'); + + const allFiles = []; + walkWorkFiles(workFilesDir, workFilesDir, allFiles); + + // Sort by modified desc + allFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)); + + const totalCount = allFiles.length; + const files = allFiles.slice(0, WORK_FILES_CAP); + + if (files.length > 0) { + projects.push({ projectPath, shortName, files, totalCount }); + } + } + } + } catch (err) { + console.error('Error scanning work-files:', err); + } + + // Sort projects by most recent file modified date + projects.sort((a, b) => { + const aMax = a.files.length > 0 ? new Date(a.files[0].modified).getTime() : 0; + const bMax = b.files.length > 0 ? new Date(b.files[0].modified).getTime() : 0; + return bMax - aMax; + }); + + // Index for FTS — text files ≤ 64KB, skip .jsonl + try { + deleteSearchType('work-file'); + const TEXT_MAX = 64 * 1024; + const entries = projects.flatMap(proj => + proj.files.map(f => { + let body = ''; + if (!f.relativePath.endsWith('.jsonl') && f.size <= TEXT_MAX) { + try { body = fs.readFileSync(f.filePath, 'utf8'); } catch {} + } + return { + id: f.filePath, type: 'work-file', folder: null, + title: proj.shortName + ' ' + f.relativePath, + body, + }; + }) + ); + upsertSearchEntries(entries); + } catch {} + + return { projects }; +}); + +// --- IPC: read-work-file --- +ipcMain.handle('read-work-file', (_event, filePath) => { + try { + const resolved = path.resolve(filePath); + // Security: path must contain /.work-files/ segment + if (!resolved.includes('/.work-files/') && !resolved.includes('\\.work-files\\')) { + return '[access denied]'; + } + if (!fs.existsSync(resolved)) return ''; + const stat = fs.statSync(resolved); + if (stat.size > 2 * 1024 * 1024) return '[file too large to display]'; + // Detect binary: try reading as utf8; if it fails or contains null bytes, treat as binary + const buf = fs.readFileSync(resolved); + if (buf.includes(0)) return '[binary file]'; + return buf.toString('utf8'); + } catch (err) { + console.error('Error reading work file:', err); + return ''; + } +}); + // --- IPC: search --- ipcMain.handle('search', (_event, type, query, titleOnly) => { return searchByType(type, query, 50, !!titleOnly); diff --git a/preload.js b/preload.js index 6961f56d..4918d621 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld('api', { getMemories: () => ipcRenderer.invoke('get-memories'), readMemory: (filePath) => ipcRenderer.invoke('read-memory', filePath), saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content), + getWorkFiles: () => ipcRenderer.invoke('get-work-files'), + readWorkFile: (filePath) => ipcRenderer.invoke('read-work-file', filePath), getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), rebuildCache: () => ipcRenderer.invoke('rebuild-cache'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), diff --git a/public/app.js b/public/app.js index caa40c21..6ac405c1 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,12 @@ const memoryPanel = new ViewerPanel(memoryViewer, { language: 'markdown', storageKey: 'markdownPreviewMode', onSave: (filePath, content) => window.api.saveMemory(filePath, content), }); +const workFilesContent = document.getElementById('work-files-content'); +const workFilesViewer = document.getElementById('work-files-viewer'); +const workFilesPanel = new ViewerPanel(workFilesViewer, { + copyPath: true, copyContent: true, + language: 'auto', storageKey: 'workFilesPreviewMode', +}); const terminalArea = document.getElementById('terminal-area'); const settingsViewer = document.getElementById('settings-viewer'); const globalSettingsBtn = document.getElementById('global-settings-btn'); @@ -453,6 +459,8 @@ function clearSearch() { renderPlans(cachedPlans); } else if (activeTab === 'memory') { renderMemories(); + } else if (activeTab === 'work-files') { + renderWorkFiles(); } } @@ -492,6 +500,10 @@ async function runSearchQuery() { const results = await window.api.search('memory', query, searchTitlesOnly); const matchIds = new Set(results.map(r => r.id)); renderMemories(matchIds); + } else if (activeTab === 'work-files') { + const results = await window.api.search('work-file', query, searchTitlesOnly); + const matchIds = new Set(results.map(r => r.id)); + renderWorkFiles(matchIds); } } catch { if (activeTab === 'sessions') { @@ -874,6 +886,7 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { plansContent.style.display = 'none'; statsContent.style.display = 'none'; memoryContent.style.display = 'none'; + workFilesContent.style.display = 'none'; sessionFilters.style.display = 'none'; searchBar.style.display = 'none'; @@ -922,6 +935,11 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { searchInput.placeholder = 'Search agent files...'; memoryContent.style.display = ''; loadMemories(); + } else if (tabName === 'work-files') { + searchBar.style.display = ''; + searchInput.placeholder = 'Search work files...'; + workFilesContent.style.display = ''; + loadWorkFiles(); } }); }); diff --git a/public/icons.js b/public/icons.js index f6d368b4..de789540 100644 --- a/public/icons.js +++ b/public/icons.js @@ -1,5 +1,6 @@ // Shared SVG icon strings window.ICONS = { + workFiles: (size = 18) => ``, archive: (size = 12) => ``, launchConfig: (size = 14) => ``, schedule: (size = 14) => ``, diff --git a/public/index.html b/public/index.html index 64155567..d496866f 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ + @@ -42,6 +43,7 @@
Click the Stats tab to view activity heatmap.
+
@@ -55,6 +57,7 @@
+