diff --git a/electron/api/routes/workspace.ts b/electron/api/routes/workspace.ts new file mode 100644 index 000000000..6741cf6f2 --- /dev/null +++ b/electron/api/routes/workspace.ts @@ -0,0 +1,298 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { readdir, readFile, stat } from 'fs/promises'; +import { join, extname, relative, resolve, normalize } from 'path'; +import { homedir } from 'os'; +import type { HostApiContext } from '../context'; +import { sendJson, setCorsHeaders } from '../route-utils'; +import { listAgentsSnapshot } from '../../utils/agent-config'; + +interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; +} + +const TEXT_EXTENSIONS = new Set([ + '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.xml', '.csv', + '.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', + '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.sh', '.bash', + '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.html', '.htm', '.css', + '.scss', '.less', '.sql', '.graphql', '.proto', '.lua', '.r', + '.m', '.mm', '.pl', '.pm', '.php', '.vue', '.svelte', '.astro', + '.env', '.ini', '.cfg', '.conf', '.log', '.diff', '.patch', + '.dockerfile', '.gitignore', '.editorconfig', '.prettierrc', + '.eslintrc', '.babelrc', +]); + +const IMAGE_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', +]); + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit for text previews +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB limit for images + +function expandPath(p: string): string { + if (p.startsWith('~')) { + return p.replace('~', homedir()); + } + return p; +} + +/** + * Validate that a requested path is within the allowed workspace root. + * Prevents path traversal attacks. + */ +function isPathWithinRoot(root: string, requestedPath: string): boolean { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(root, requestedPath); + return resolvedPath.startsWith(resolvedRoot); +} + +async function buildFileTree( + dirPath: string, + rootPath: string, + depth: number = 0, + maxDepth: number = 10, +): Promise { + if (depth >= maxDepth) return []; + + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + + const nodes: FileTreeNode[] = []; + + for (const entry of entries) { + // Skip hidden files/dirs and common unneeded dirs + if (entry.name.startsWith('.') && entry.name !== '.env') continue; + if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue; + + const fullPath = join(dirPath, entry.name); + const relativePath = relative(rootPath, fullPath); + + if (entry.isDirectory()) { + const children = await buildFileTree(fullPath, rootPath, depth + 1, maxDepth); + nodes.push({ + name: entry.name, + path: relativePath, + type: 'directory', + children, + }); + } else { + nodes.push({ + name: entry.name, + path: relativePath, + type: 'file', + }); + } + } + + return nodes; +} + +function getFileType(filePath: string): 'text' | 'image' | 'html' | 'binary' { + const ext = extname(filePath).toLowerCase(); + if (ext === '.html' || ext === '.htm') return 'html'; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (TEXT_EXTENSIONS.has(ext)) return 'text'; + // Files with no extension are often text (README, Makefile, etc.) + if (!ext) return 'text'; + return 'binary'; +} + +function getLanguageFromExt(ext: string): string { + const map: Record = { + '.js': 'javascript', '.jsx': 'javascript', + '.ts': 'typescript', '.tsx': 'typescript', + '.py': 'python', + '.rb': 'ruby', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.c': 'c', '.h': 'c', + '.cpp': 'cpp', '.hpp': 'cpp', + '.cs': 'csharp', + '.swift': 'swift', + '.kt': 'kotlin', + '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash', + '.html': 'html', '.htm': 'html', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.sql': 'sql', + '.json': 'json', + '.yaml': 'yaml', '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.md': 'markdown', + '.php': 'php', + '.lua': 'lua', + '.r': 'r', + '.graphql': 'graphql', + '.proto': 'protobuf', + '.dockerfile': 'dockerfile', + '.vue': 'vue', + '.svelte': 'svelte', + }; + return map[ext.toLowerCase()] || 'plaintext'; +} + +export async function handleWorkspaceRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, +): Promise { + // GET /api/workspace/agents — list agents with their workspace paths + if (url.pathname === '/api/workspace/agents' && req.method === 'GET') { + try { + const snapshot = await listAgentsSnapshot(); + const agents = snapshot.agents.map((a) => ({ + id: a.id, + name: a.name, + workspace: expandPath(a.workspace), + isDefault: a.isDefault, + })); + sendJson(res, 200, { success: true, agents }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + // GET /api/workspace/tree?agent= — get file tree for agent's workspace + if (url.pathname === '/api/workspace/tree' && req.method === 'GET') { + try { + const agentId = url.searchParams.get('agent') || 'main'; + const snapshot = await listAgentsSnapshot(); + const agent = snapshot.agents.find((a) => a.id === agentId); + if (!agent) { + sendJson(res, 404, { success: false, error: `Agent "${agentId}" not found` }); + return true; + } + + const workspacePath = expandPath(agent.workspace); + const tree = await buildFileTree(workspacePath, workspacePath); + sendJson(res, 200, { + success: true, + agentId: agent.id, + agentName: agent.name, + workspace: workspacePath, + tree, + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + // GET /api/workspace/file?agent=&path= — read file content + if (url.pathname === '/api/workspace/file' && req.method === 'GET') { + try { + const agentId = url.searchParams.get('agent') || 'main'; + const filePath = url.searchParams.get('path'); + + if (!filePath) { + sendJson(res, 400, { success: false, error: 'Missing "path" parameter' }); + return true; + } + + const snapshot = await listAgentsSnapshot(); + const agent = snapshot.agents.find((a) => a.id === agentId); + if (!agent) { + sendJson(res, 404, { success: false, error: `Agent "${agentId}" not found` }); + return true; + } + + const workspacePath = expandPath(agent.workspace); + const normalizedRelPath = normalize(filePath); + + // Security: prevent path traversal + if (!isPathWithinRoot(workspacePath, normalizedRelPath)) { + sendJson(res, 403, { success: false, error: 'Path traversal not allowed' }); + return true; + } + + const fullPath = join(workspacePath, normalizedRelPath); + const fileStat = await stat(fullPath); + + if (!fileStat.isFile()) { + sendJson(res, 400, { success: false, error: 'Not a file' }); + return true; + } + + const ext = extname(fullPath).toLowerCase(); + const fileType = getFileType(fullPath); + + if (fileType === 'image') { + if (fileStat.size > MAX_IMAGE_SIZE) { + sendJson(res, 413, { success: false, error: 'Image too large' }); + return true; + } + const buf = await readFile(fullPath); + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + }; + const mime = mimeMap[ext] || 'application/octet-stream'; + // Return base64 data URL for images + setCorsHeaders(res); + sendJson(res, 200, { + success: true, + fileType: 'image', + mimeType: mime, + content: `data:${mime};base64,${buf.toString('base64')}`, + size: fileStat.size, + }); + return true; + } + + if (fileType === 'text' || fileType === 'html') { + if (fileStat.size > MAX_FILE_SIZE) { + sendJson(res, 413, { success: false, error: 'File too large for preview' }); + return true; + } + const content = await readFile(fullPath, 'utf-8'); + sendJson(res, 200, { + success: true, + fileType, + language: getLanguageFromExt(ext), + content, + size: fileStat.size, + }); + return true; + } + + sendJson(res, 200, { + success: true, + fileType: 'binary', + size: fileStat.size, + message: 'Binary files cannot be previewed', + }); + } catch (error) { + const msg = String(error); + if (msg.includes('ENOENT')) { + sendJson(res, 404, { success: false, error: 'File not found' }); + } else { + sendJson(res, 500, { success: false, error: msg }); + } + } + return true; + } + + return false; +} diff --git a/electron/api/server.ts b/electron/api/server.ts index 64523d086..4d7a54d50 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -14,6 +14,7 @@ import { handleSkillRoutes } from './routes/skills'; import { handleFileRoutes } from './routes/files'; import { handleSessionRoutes } from './routes/sessions'; import { handleCronRoutes } from './routes/cron'; +import { handleWorkspaceRoutes } from './routes/workspace'; import { sendJson } from './route-utils'; type RouteHandler = ( @@ -34,6 +35,7 @@ const routeHandlers: RouteHandler[] = [ handleFileRoutes, handleSessionRoutes, handleCronRoutes, + handleWorkspaceRoutes, handleLogRoutes, handleUsageRoutes, ]; diff --git a/src/App.tsx b/src/App.tsx index 5832ec381..2396b4f85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { Channels } from './pages/Channels'; import { Skills } from './pages/Skills'; import { Cron } from './pages/Cron'; import { Settings } from './pages/Settings'; +import { Workspace } from './pages/Workspace'; import { Setup } from './pages/Setup'; import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; @@ -173,6 +174,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 86e6eb1a0..3b5daccdd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { ExternalLink, Trash2, Cpu, + FolderTree, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; @@ -211,6 +212,7 @@ export function Sidebar() { { to: '/channels', icon: , label: t('sidebar.channels') }, { to: '/skills', icon: , label: t('sidebar.skills') }, { to: '/cron', icon: , label: t('sidebar.cronTasks') }, + { to: '/workspace', icon: , label: t('sidebar.workspace') }, ]; return ( diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 58ac0f41b..eb9490dd8 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -16,6 +16,7 @@ import enAgents from './locales/en/agents.json'; import enSkills from './locales/en/skills.json'; import enCron from './locales/en/cron.json'; import enSetup from './locales/en/setup.json'; +import enWorkspace from './locales/en/workspace.json'; // ZH import zhCommon from './locales/zh/common.json'; @@ -27,6 +28,7 @@ import zhAgents from './locales/zh/agents.json'; import zhSkills from './locales/zh/skills.json'; import zhCron from './locales/zh/cron.json'; import zhSetup from './locales/zh/setup.json'; +import zhWorkspace from './locales/zh/workspace.json'; // JA import jaCommon from './locales/ja/common.json'; @@ -38,6 +40,7 @@ import jaAgents from './locales/ja/agents.json'; import jaSkills from './locales/ja/skills.json'; import jaCron from './locales/ja/cron.json'; import jaSetup from './locales/ja/setup.json'; +import jaWorkspace from './locales/ja/workspace.json'; export const SUPPORTED_LANGUAGES = [ { code: 'en', label: 'English' }, @@ -56,6 +59,7 @@ const resources = { skills: enSkills, cron: enCron, setup: enSetup, + workspace: enWorkspace, }, zh: { common: zhCommon, @@ -67,6 +71,7 @@ const resources = { skills: zhSkills, cron: zhCron, setup: zhSetup, + workspace: zhWorkspace, }, ja: { common: jaCommon, @@ -78,6 +83,7 @@ const resources = { skills: jaSkills, cron: jaCron, setup: jaSetup, + workspace: jaWorkspace, }, }; @@ -89,7 +95,7 @@ i18n fallbackLng: 'en', supportedLngs: [...SUPPORTED_LANGUAGE_CODES], defaultNS: 'common', - ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'agents', 'skills', 'cron', 'setup'], + ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'agents', 'skills', 'cron', 'setup', 'workspace'], interpolation: { escapeValue: false, // React already escapes }, diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 5d821bdb4..d90329f15 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -11,7 +11,8 @@ "devConsole": "Developer Console", "models": "Models", "deleteSessionConfirm": "Are you sure you want to delete session \"{{label}}\"?", - "openClawPage": "OpenClaw Page" + "openClawPage": "OpenClaw Page", + "workspace": "Workspace" }, "actions": { "save": "Save", diff --git a/src/i18n/locales/en/workspace.json b/src/i18n/locales/en/workspace.json new file mode 100644 index 000000000..786b6398e --- /dev/null +++ b/src/i18n/locales/en/workspace.json @@ -0,0 +1,11 @@ +{ + "title": "Workspace", + "subtitle": "Browse and preview files in your agent workspaces", + "refresh": "Refresh", + "revealInFinder": "Reveal in Finder", + "revealInExplorer": "Reveal in Explorer", + "revealInFileManager": "Open in File Manager", + "selectFileToPreview": "Select a file to preview", + "binaryNotSupported": "Binary files cannot be previewed", + "emptyWorkspace": "This workspace is empty" +} diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 04907952d..d0762b22d 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -11,7 +11,8 @@ "devConsole": "開発者コンソール", "models": "モデル", "deleteSessionConfirm": "セッション \"{{label}}\" を削除してもよろしいですか?", - "openClawPage": "OpenClaw ページ" + "openClawPage": "OpenClaw ページ", + "workspace": "ワークスペース" }, "actions": { "save": "保存", diff --git a/src/i18n/locales/ja/workspace.json b/src/i18n/locales/ja/workspace.json new file mode 100644 index 000000000..91fd5118e --- /dev/null +++ b/src/i18n/locales/ja/workspace.json @@ -0,0 +1,11 @@ +{ + "title": "ワークスペース", + "subtitle": "エージェントワークスペース内のファイルを閲覧・プレビュー", + "refresh": "更新", + "revealInFinder": "Finder で表示", + "revealInExplorer": "エクスプローラーで表示", + "revealInFileManager": "ファイルマネージャーで開く", + "selectFileToPreview": "ファイルを選択してプレビュー", + "binaryNotSupported": "バイナリファイルはプレビューできません", + "emptyWorkspace": "ワークスペースは空です" +} diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index eb73c05b9..7e0865c8e 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -11,7 +11,8 @@ "devConsole": "开发者控制台", "models": "模型", "deleteSessionConfirm": "确定要删除对话 \"{{label}}\" 吗?", - "openClawPage": "OpenClaw 页面" + "openClawPage": "OpenClaw 页面", + "workspace": "工作空间" }, "actions": { "save": "保存", diff --git a/src/i18n/locales/zh/workspace.json b/src/i18n/locales/zh/workspace.json new file mode 100644 index 000000000..d0dc4b40f --- /dev/null +++ b/src/i18n/locales/zh/workspace.json @@ -0,0 +1,11 @@ +{ + "title": "工作空间", + "subtitle": "浏览和预览 Agent 工作空间中的文件", + "refresh": "刷新", + "revealInFinder": "在 Finder 中显示", + "revealInExplorer": "在资源管理器中显示", + "revealInFileManager": "在文件管理器中打开", + "selectFileToPreview": "选择一个文件进行预览", + "binaryNotSupported": "无法预览二进制文件", + "emptyWorkspace": "工作空间为空" +} diff --git a/src/pages/Workspace/index.tsx b/src/pages/Workspace/index.tsx new file mode 100644 index 000000000..4f171d766 --- /dev/null +++ b/src/pages/Workspace/index.tsx @@ -0,0 +1,905 @@ +/** + * Workspace Page + * Browse and preview files in agent workspaces + */ +import { useEffect, useState, useCallback } from 'react'; +import { + FolderOpen, + File, + ChevronRight, + ChevronDown, + RefreshCw, + FileText, + Image, + Code, + Globe, + FileQuestion, + ChevronsUpDown, + Check, + ExternalLink, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { hostApiFetch } from '@/lib/host-api'; +import { invokeIpc } from '@/lib/api-client'; +import { useTranslation } from 'react-i18next'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; +} + +interface WorkspaceAgent { + id: string; + name: string; + workspace: string; + isDefault: boolean; +} + +interface FileContent { + success: boolean; + fileType: 'text' | 'image' | 'html' | 'binary'; + content?: string; + language?: string; + mimeType?: string; + size?: number; + message?: string; + error?: string; +} + +// ─── Markdown Renderer ─────────────────────────────────────────────────────── + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderInlineMarkdown(text: string): string { + let result = escapeHtml(text); + // Bold + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/__(.+?)__/g, '$1'); + // Italic + result = result.replace(/\*(.+?)\*/g, '$1'); + result = result.replace(/_(.+?)_/g, '$1'); + // Inline code + result = result.replace(/`([^`]+)`/g, '$1'); + // Links + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + // Strikethrough + result = result.replace(/~~(.+?)~~/g, '$1'); + return result; +} + +function renderMarkdownTable(lines: string[], startIndex: number): { html: string; endIndex: number } { + const tableLines: string[] = []; + let i = startIndex; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i].trim()); + i++; + } + if (tableLines.length < 2) return { html: '', endIndex: startIndex }; + + const parseRow = (line: string) => + line.split('|').slice(1, -1).map((cell) => cell.trim()); + + const headers = parseRow(tableLines[0]); + // Skip separator row (index 1) + const bodyRows = tableLines.slice(2).map(parseRow); + + // Parse alignment from separator row + const separatorCells = parseRow(tableLines[1]); + const aligns = separatorCells.map((cell) => { + const trimmed = cell.replace(/\s/g, ''); + if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center'; + if (trimmed.endsWith(':')) return 'right'; + return 'left'; + }); + + let html = '
'; + html += ''; + headers.forEach((h, idx) => { + const align = aligns[idx] || 'left'; + html += ``; + }); + html += ''; + bodyRows.forEach((row) => { + html += ''; + headers.forEach((_, idx) => { + const align = aligns[idx] || 'left'; + const cell = row[idx] || ''; + html += ``; + }); + html += ''; + }); + html += '
${renderInlineMarkdown(h)}
${renderInlineMarkdown(cell)}
'; + + return { html, endIndex: i }; +} + +function renderMarkdownToHtml(markdown: string): string { + const lines = markdown.split('\n'); + let html = ''; + let i = 0; + let inCodeBlock = false; + let codeBlockLang = ''; + let codeBlockContent = ''; + let inList = false; + let listType: 'ul' | 'ol' = 'ul'; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // Code blocks + if (trimmed.startsWith('```')) { + if (inCodeBlock) { + const highlighted = highlightCode(codeBlockContent.trimEnd(), codeBlockLang); + html += `
`; + if (codeBlockLang) { + html += `
${escapeHtml(codeBlockLang)}
`; + } + html += `
${highlighted}
`; + inCodeBlock = false; + codeBlockContent = ''; + codeBlockLang = ''; + } else { + // Close any open list + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + inCodeBlock = true; + codeBlockLang = trimmed.slice(3).trim(); + } + i++; + continue; + } + + if (inCodeBlock) { + codeBlockContent += line + '\n'; + i++; + continue; + } + + // Table + if (trimmed.startsWith('|') && i + 1 < lines.length && lines[i + 1].trim().match(/^\|[\s:|-]+\|$/)) { + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + const table = renderMarkdownTable(lines, i); + if (table.html) { + html += table.html; + i = table.endIndex; + continue; + } + } + + // Empty line + if (!trimmed) { + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + i++; + continue; + } + + // Headings + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + const level = headingMatch[1].length; + const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm']; + const mt = level <= 2 ? 'mt-6' : 'mt-4'; + html += `${renderInlineMarkdown(headingMatch[2])}`; + i++; + continue; + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + html += '
'; + i++; + continue; + } + + // Blockquote + if (trimmed.startsWith('> ')) { + if (inList) { + html += listType === 'ul' ? '' : ''; + inList = false; + } + let quoteContent = ''; + while (i < lines.length && lines[i].trim().startsWith('> ')) { + quoteContent += lines[i].trim().slice(2) + '\n'; + i++; + } + html += `
${renderInlineMarkdown(quoteContent.trim())}
`; + continue; + } + + // Unordered list + const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/); + if (ulMatch) { + if (!inList || listType !== 'ul') { + if (inList) html += listType === 'ul' ? '' : ''; + html += '
    '; + inList = true; + listType = 'ul'; + } + html += `
  • ${renderInlineMarkdown(ulMatch[1])}
  • `; + i++; + continue; + } + + // Ordered list + const olMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (olMatch) { + if (!inList || listType !== 'ol') { + if (inList) html += listType === 'ul' ? '
' : ''; + html += '
    '; + inList = true; + listType = 'ol'; + } + html += `
  1. ${renderInlineMarkdown(olMatch[1])}
  2. `; + i++; + continue; + } + + // Paragraph + if (inList) { + html += listType === 'ul' ? '' : '
'; + inList = false; + } + html += `

${renderInlineMarkdown(trimmed)}

`; + i++; + } + + if (inList) html += listType === 'ul' ? '' : ''; + + return html; +} + +// ─── Code Syntax Highlighter ───────────────────────────────────────────────── + +const KEYWORD_STYLES: Record = { + python: [ + { keywords: ['def', 'class', 'import', 'from', 'return', 'if', 'elif', 'else', 'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'yield', 'lambda', 'pass', 'break', 'continue', 'raise', 'and', 'or', 'not', 'in', 'is', 'async', 'await', 'global', 'nonlocal', 'assert', 'del'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['True', 'False', 'None', 'self', 'cls'], className: 'text-orange-500 dark:text-orange-400' }, + { keywords: ['print', 'len', 'range', 'int', 'str', 'float', 'list', 'dict', 'set', 'tuple', 'bool', 'type', 'isinstance', 'enumerate', 'zip', 'map', 'filter', 'sorted', 'open', 'super', 'property', 'staticmethod', 'classmethod'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], + javascript: [ + { keywords: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'new', 'delete', 'typeof', 'instanceof', 'in', 'of', 'class', 'extends', 'import', 'export', 'default', 'from', 'async', 'await', 'yield', 'this', 'super', 'static', 'get', 'set'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity'], className: 'text-orange-500 dark:text-orange-400' }, + { keywords: ['console', 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Map', 'Set', 'JSON', 'Math', 'Date', 'Error', 'RegExp', 'Symbol', 'parseInt', 'parseFloat', 'setTimeout', 'setInterval', 'fetch', 'require', 'module', 'exports'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], + typescript: [ + { keywords: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'new', 'delete', 'typeof', 'instanceof', 'in', 'of', 'class', 'extends', 'import', 'export', 'default', 'from', 'async', 'await', 'yield', 'this', 'super', 'static', 'get', 'set', 'type', 'interface', 'enum', 'namespace', 'declare', 'implements', 'abstract', 'readonly', 'as', 'is', 'keyof', 'infer', 'satisfies'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'void', 'never', 'unknown', 'any', 'string', 'number', 'boolean', 'object', 'symbol', 'bigint'], className: 'text-orange-500 dark:text-orange-400' }, + { keywords: ['console', 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Map', 'Set', 'JSON', 'Math', 'Date', 'Error', 'RegExp', 'Symbol', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit', 'Exclude', 'Extract', 'NonNullable', 'ReturnType', 'Parameters'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], + go: [ + { keywords: ['func', 'package', 'import', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'default', 'break', 'continue', 'go', 'select', 'chan', 'defer', 'fallthrough', 'goto', 'map', 'struct', 'interface', 'type', 'const', 'var'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['true', 'false', 'nil', 'iota'], className: 'text-orange-500 dark:text-orange-400' }, + { keywords: ['fmt', 'make', 'len', 'cap', 'append', 'copy', 'close', 'delete', 'new', 'panic', 'recover', 'print', 'println', 'error', 'string', 'int', 'float64', 'bool', 'byte', 'rune'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], + rust: [ + { keywords: ['fn', 'let', 'mut', 'const', 'static', 'pub', 'mod', 'use', 'crate', 'self', 'super', 'struct', 'enum', 'trait', 'impl', 'type', 'where', 'for', 'in', 'loop', 'while', 'if', 'else', 'match', 'return', 'break', 'continue', 'as', 'ref', 'move', 'async', 'await', 'dyn', 'unsafe', 'extern'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['true', 'false', 'Some', 'None', 'Ok', 'Err', 'Self'], className: 'text-orange-500 dark:text-orange-400' }, + { keywords: ['println', 'print', 'format', 'vec', 'String', 'Vec', 'Box', 'Option', 'Result', 'HashMap', 'HashSet', 'Rc', 'Arc', 'Mutex', 'Clone', 'Copy', 'Debug', 'Display', 'Default', 'Iterator', 'From', 'Into'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], + bash: [ + { keywords: ['if', 'then', 'else', 'elif', 'fi', 'for', 'do', 'done', 'while', 'until', 'case', 'esac', 'in', 'function', 'return', 'local', 'export', 'source', 'alias', 'unalias', 'set', 'unset', 'shift', 'exit', 'trap', 'readonly'], className: 'text-purple-500 dark:text-purple-400' }, + { keywords: ['echo', 'printf', 'read', 'cd', 'pwd', 'ls', 'mkdir', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'find', 'xargs', 'sort', 'uniq', 'wc', 'head', 'tail', 'cut', 'tr', 'tee', 'chmod', 'chown', 'curl', 'wget'], className: 'text-cyan-500 dark:text-cyan-400' }, + ], +}; + +// Use javascript rules as fallback for unknown languages +function getKeywordRulesForLang(lang: string): typeof KEYWORD_STYLES.python { + if (lang === 'jsx') return KEYWORD_STYLES.javascript; + if (lang === 'tsx') return KEYWORD_STYLES.typescript; + return KEYWORD_STYLES[lang] || KEYWORD_STYLES.javascript; +} + +function highlightCode(code: string, language: string): string { + const escaped = escapeHtml(code); + const lines = escaped.split('\n'); + const rules = getKeywordRulesForLang(language); + const PH = String.fromCharCode(0); + + return lines.map((line) => { + // Every highlight pass stores its output as a placeholder token so that + // subsequent regex passes never see previously-generated HTML attributes. + const tokens: string[] = []; + const hold = (html: string) => { + const idx = tokens.length; + tokens.push(html); + return `${PH}T${idx}${PH}`; + }; + + let result = line; + + // 1. Comments — full-line early return + const commentPatterns = [ + /^(\s*)(\/\/.*)$/, // // + /^(\s*)(#.*)$/, // # + /^(\s*)(--.*)$/, // -- + ]; + for (const pattern of commentPatterns) { + const match = result.match(pattern); + if (match) { + return `${match[1]}${match[2]}`; + } + } + + // 2. Double-quoted strings → placeholder + result = result.replace( + /(")((?:[^&]|&(?!quot;))*)(")/g, + (_, q1, body, q2) => hold(`${q1 as string}${body as string}${q2 as string}`) + ); + + // 3. Single-quoted strings → placeholder + result = result.replace( + /(')((?:[^&]|&(?!#x27;))*)(')/g, + (_, q1, body, q2) => hold(`${q1 as string}${body as string}${q2 as string}`) + ); + + // 4. Numbers → placeholder + result = result.replace( + /\b(\d+\.?\d*(?:e[+-]?\d+)?)\b/gi, + (_, n) => hold(`${n as string}`) + ); + + // 5. Keywords → placeholder + for (const rule of rules) { + for (const keyword of rule.keywords) { + const regex = new RegExp(`\\b(${keyword})\\b`, 'g'); + result = result.replace(regex, (_, kw) => hold(`${kw as string}`)); + } + } + + // 6. Restore all placeholders + const phRegex = new RegExp(`${PH}T(\\d+)${PH}`, 'g'); + result = result.replace(phRegex, (_, idx) => tokens[Number(idx)]); + + return result; + }).join('\n'); +} + +// ─── File Icon Helper ──────────────────────────────────────────────────────── + +function getFileIcon(name: string) { + const ext = name.includes('.') ? '.' + name.split('.').pop()!.toLowerCase() : ''; + if (['.md', '.txt', '.log'].includes(ext)) return ; + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'].includes(ext)) return ; + if (['.html', '.htm'].includes(ext)) return ; + if (['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.cs', '.swift', '.kt', '.sh', '.php', '.lua', '.r'].includes(ext)) return ; + if (['.json', '.yaml', '.yml', '.toml', '.xml', '.csv', '.ini', '.cfg', '.conf', '.env'].includes(ext)) return ; + return ; +} + +// ─── File Tree Component ───────────────────────────────────────────────────── + +function FileTreeItem({ + node, + selectedPath, + onSelectFile, + depth = 0, +}: { + node: FileTreeNode; + selectedPath: string | null; + onSelectFile: (path: string) => void; + depth?: number; +}) { + const [expanded, setExpanded] = useState(depth < 1); + + if (node.type === 'directory') { + return ( +
+ + {expanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); + } + + return ( + + ); +} + +// ─── File Preview Component ────────────────────────────────────────────────── + +function FilePreview({ + fileContent, + filePath, + loading, +}: { + fileContent: FileContent | null; + filePath: string | null; + loading: boolean; +}) { + const { t } = useTranslation('workspace'); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!filePath || !fileContent) { + return ( +
+ +

{t('selectFileToPreview')}

+
+ ); + } + + if (fileContent.error) { + return ( +
+
+ {fileContent.error} +
+
+ ); + } + + const fileName = filePath.split('/').pop() || filePath; + + // Image preview + if (fileContent.fileType === 'image' && fileContent.content) { + return ( +
+
+ + {filePath} + {fileContent.size && ( + {formatFileSize(fileContent.size)} + )} +
+
+ {fileName} +
+
+ ); + } + + // HTML preview + if (fileContent.fileType === 'html' && fileContent.content) { + return ( +
+
+ + {filePath} + {fileContent.size && ( + {formatFileSize(fileContent.size)} + )} +
+
+