diff --git a/resources/installer.nsh b/resources/installer.nsh index d58f189..cf12ae7 100644 --- a/resources/installer.nsh +++ b/resources/installer.nsh @@ -37,9 +37,11 @@ !macro customRemoveFiles SetDetailsPrint both DetailPrint "正在删除程序文件,请稍候..." - SetDetailsPrint none - RMDir /r $INSTDIR - SetDetailsPrint both + ; 用 rd /s /q 替代 NSIS 逐文件删除,处理 node_modules 海量文件快几个量级 + nsExec::ExecToStack 'cmd /c rd /s /q "$INSTDIR"' + Pop $0 + ; rd 只能删内容不删自身(如果当前目录在其中),兜底清理 + RMDir $INSTDIR DetailPrint "程序文件删除完成。" !macroend @@ -60,14 +62,16 @@ 选择「否」则保留,重新安装后可自动恢复。" \ IDNO done - ; 删除默认 userData 目录 - RMDir /r "$APPDATA\easiest-claw-desktop" + ; 删除默认 userData 目录(rd /s /q 比 RMDir /r 快很多) + nsExec::ExecToStack 'cmd /c rd /s /q "$APPDATA\easiest-claw-desktop"' + Pop $0 DetailPrint "默认用户数据已清除。" ; 删除自定义数据目录(从注册表读取) ReadRegStr $0 HKCU "Software\EasiestClaw" "DataDir" ${If} $0 != "" - RMDir /r "$0" + nsExec::ExecToStack 'cmd /c rd /s /q "$0"' + Pop $1 DetailPrint "自定义数据目录已清除: $0" ${EndIf} diff --git a/src/main/ipc/agents.ts b/src/main/ipc/agents.ts index d36dd12..3162e49 100644 --- a/src/main/ipc/agents.ts +++ b/src/main/ipc/agents.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' -import type { IpcMain } from 'electron' +import { shell, type IpcMain } from 'electron' import { gw } from './gw' // ── Workspace path cache (per agentId) ────────────────────────────────────── @@ -104,6 +104,140 @@ export const registerAgentHandlers = (ipcMain: IpcMain): void => { return gw('status', {}) }) + // ── Full workspace tree scan (filesystem-based) ──────────────────────────── + + const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.next', '.cache', '.venv', '.turbo', 'dist', '.DS_Store']) + const MAX_DEPTH = 5 + const MAX_FILES = 2000 + + interface TreeNode { + name: string + type: 'file' | 'dir' + path: string + size?: number + updatedAtMs?: number + children?: TreeNode[] + } + + async function scanDir(dir: string, basePath: string, depth: number, counter: { count: number }): Promise { + if (depth > MAX_DEPTH || counter.count >= MAX_FILES) return [] + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + + const dirs: TreeNode[] = [] + const files: TreeNode[] = [] + + for (const entry of entries) { + if (counter.count >= MAX_FILES) break + if (entry.name.startsWith('.') && SKIP_DIRS.has(entry.name)) continue + if (SKIP_DIRS.has(entry.name)) continue + + const fullPath = path.join(dir, entry.name) + const relativePath = path.join(basePath, entry.name).replace(/\\/g, '/') + + if (entry.isDirectory()) { + const children = await scanDir(fullPath, relativePath, depth + 1, counter) + dirs.push({ name: entry.name, type: 'dir', path: relativePath, children }) + } else if (entry.isFile()) { + counter.count++ + try { + const stat = await fs.stat(fullPath) + files.push({ name: entry.name, type: 'file', path: relativePath, size: stat.size, updatedAtMs: stat.mtimeMs }) + } catch { + files.push({ name: entry.name, type: 'file', path: relativePath }) + } + } + } + + // Sort: dirs first (alpha), then files (alpha) + dirs.sort((a, b) => a.name.localeCompare(b.name)) + files.sort((a, b) => a.name.localeCompare(b.name)) + return [...dirs, ...files] + } + + ipcMain.handle('agents:workspace:tree', async (_event, params: { agentId: string }) => { + const workspace = await resolveWorkspaceDir(params.agentId) + if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' } + + try { + const realWorkspace = await fs.realpath(workspace).catch(() => workspace) + const tree = await scanDir(realWorkspace, '', 0, { count: 0 }) + return { ok: true, workspace, tree } + } catch (err) { + return { ok: false, error: `Failed to scan workspace: ${(err as Error).message}` } + } + }) + + ipcMain.handle('agents:workspace:read', async (_event, params: { agentId: string; filePath: string }) => { + const workspace = await resolveWorkspaceDir(params.agentId) + if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' } + + const filePath = params.filePath + if (!filePath || filePath.includes('..') || path.isAbsolute(filePath)) { + return { ok: false, error: 'Invalid file path' } + } + + const fullPath = path.join(workspace, filePath) + const realPath = await fs.realpath(fullPath).catch(() => fullPath) + const realWorkspace = await fs.realpath(workspace).catch(() => workspace) + if (!realPath.startsWith(realWorkspace)) { + return { ok: false, error: 'Path escapes workspace' } + } + + try { + const stat = await fs.stat(fullPath) + if (stat.size > 1024 * 1024) { + return { ok: true, content: null, tooLarge: true } + } + // Binary detection: read first 512 bytes and check for NUL + const buf = Buffer.alloc(Math.min(512, stat.size)) + const fh = await fs.open(fullPath, 'r') + try { + await fh.read(buf, 0, buf.length, 0) + } finally { + await fh.close() + } + if (buf.includes(0)) { + return { ok: true, content: null, binary: true } + } + const content = await fs.readFile(fullPath, 'utf8') + return { ok: true, content } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: false, error: 'File not found' } + } + return { ok: false, error: `Failed to read file: ${(err as Error).message}` } + } + }) + + // ── Open file with system default application ────────────────────────────── + ipcMain.handle('agents:workspace:open', async (_event, params: { agentId: string; filePath: string }) => { + const workspace = await resolveWorkspaceDir(params.agentId) + if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' } + + const filePath = params.filePath + if (!filePath || filePath.includes('..') || path.isAbsolute(filePath)) { + return { ok: false, error: 'Invalid file path' } + } + + const fullPath = path.join(workspace, filePath) + const realPath = await fs.realpath(fullPath).catch(() => fullPath) + const realWorkspace = await fs.realpath(workspace).catch(() => workspace) + if (!realPath.startsWith(realWorkspace)) { + return { ok: false, error: 'Path escapes workspace' } + } + + const errorMessage = await shell.openPath(realPath) + if (errorMessage) { + return { ok: false, error: errorMessage } + } + return { ok: true } + }) + // ── Memory directory listing (filesystem-based, gateway doesn't expose this) ─ ipcMain.handle('agents:memory:list', async (_event, params: { agentId: string }) => { const workspace = await resolveWorkspaceDir(params.agentId) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index e2abbc7..cecffd2 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,8 +1,73 @@ import type { IpcMain } from 'electron' +import { net } from 'electron' +import { spawn } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' import { gw } from './gw' import { isRecord, readOpenclawConfig as readConfig, writeOpenclawConfig as writeConfig } from '../lib/openclaw-config' +import { getBundledNpmBin } from '../lib/openclaw-paths' + +const CLAWHUB_REGISTRY = process.env.CLAWHUB_REGISTRY || 'https://clawhub.ai' + +// ── In-memory cache to avoid 429 rate limits ───────────────────────────────── +interface CacheEntry { data: T; expiresAt: number } +const apiCache = new Map>() + +function getCached(key: string): T | undefined { + const entry = apiCache.get(key) + if (!entry) return undefined + if (Date.now() > entry.expiresAt) { + apiCache.delete(key) + return undefined + } + return entry.data as T +} + +function setCache(key: string, data: T, ttlMs: number): void { + apiCache.set(key, { data, expiresAt: Date.now() + ttlMs }) +} + +const CACHE_TTL_EXPLORE = 15 * 60_000 // 15 min for explore/browse +const CACHE_TTL_SEARCH = 5 * 60_000 // 5 min for search + +/** Helper: make a JSON GET request to the ClawHub API */ +async function clawHubGet(path: string): Promise { + const url = `${CLAWHUB_REGISTRY}${path}` + console.log(`[ClawHub] GET ${url}`) + return new Promise((resolve, reject) => { + const request = net.request({ url, method: 'GET' }) + request.setHeader('Accept', 'application/json') + request.setHeader('User-Agent', 'EasiestClaw-Desktop') + + let body = '' + request.on('response', (response) => { + console.log(`[ClawHub] ${response.statusCode} ${url}`) + if (response.statusCode < 200 || response.statusCode >= 300) { + response.on('data', (chunk) => { body += chunk.toString() }) + response.on('end', () => reject(new Error(`HTTP ${response.statusCode}: ${body}`))) + return + } + response.on('data', (chunk) => { body += chunk.toString() }) + response.on('end', () => { + try { resolve(JSON.parse(body) as T) } + catch { reject(new Error('Invalid JSON response')) } + }) + }) + request.on('error', (err) => { + console.error(`[ClawHub] Error ${url}:`, err.message) + reject(err) + }) + request.end() + }) +} export const registerSkillsHandlers = (ipcMain: IpcMain): void => { + // Clear API cache (used by marketplace refresh button) + ipcMain.handle('clawhub:cache-clear', () => { + apiCache.clear() + return { ok: true } + }) + // Get global skills list from gateway ipcMain.handle('skills:list', async () => { return gw('skills.status', {}) @@ -13,6 +78,137 @@ export const registerSkillsHandlers = (ipcMain: IpcMain): void => { return gw('skills.update', { name: params.name, enabled: params.enabled }) }) + // Search ClawHub marketplace + ipcMain.handle('clawhub:search', async (_event, params: { query: string; limit?: number }) => { + try { + const q = encodeURIComponent(params.query) + const limit = params.limit ?? 20 + const cacheKey = `search:${q}:${limit}` + const cached = getCached<{ ok: true; results: unknown[] }>(cacheKey) + if (cached) return cached + const data = await clawHubGet<{ results?: unknown[] }>(`/api/v1/search?q=${q}&limit=${limit}`) + const result = { ok: true as const, results: Array.isArray(data.results) ? data.results : [] } + setCache(cacheKey, result, CACHE_TTL_SEARCH) + return result + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Search failed' } + } + }) + + // Browse / explore ClawHub skills + // NOTE: /api/v1/skills browse endpoint returns empty results, so we use + // the search endpoint with a broad query as a workaround. + ipcMain.handle('clawhub:explore', async (_event, params: { limit?: number; cursor?: string }) => { + try { + const limit = params.limit ?? 30 + const cacheKey = `explore:${limit}:${params.cursor ?? ''}` + const cached = getCached<{ ok: true; items: unknown[]; nextCursor: string | null }>(cacheKey) + if (cached) { + console.log(`[ClawHub] explore cache hit (${cached.items.length} items)`) + return cached + } + // Use search with broad queries to populate the "explore" view + const queries = ['skill', 'agent', 'code', 'web', 'data'] + const allItems = new Map() + for (const q of queries) { + if (allItems.size >= limit) break + try { + const data = await clawHubGet<{ results?: unknown[] }>(`/api/v1/search?q=${q}&limit=${limit}`) + if (Array.isArray(data.results)) { + for (const item of data.results) { + const slug = (item as Record).slug as string + if (slug && !allItems.has(slug)) allItems.set(slug, item) + } + } + } catch { /* skip failed queries */ } + } + console.log(`[ClawHub] explore fetched ${allItems.size} unique items via search`) + const result = { + ok: true as const, + items: [...allItems.values()].slice(0, limit), + nextCursor: null, + } + setCache(cacheKey, result, CACHE_TTL_EXPLORE) + return result + } catch (err) { + console.error(`[ClawHub] explore error:`, err instanceof Error ? err.message : err) + return { ok: false, error: err instanceof Error ? err.message : 'Failed to fetch skills' } + } + }) + + // Install a skill from ClawHub via `npx clawhub install ` + // Includes automatic retry on rate-limit (429) errors + ipcMain.handle('clawhub:install', async (_event, params: { name: string; installId: string; timeoutMs?: number }) => { + const slug = params.name + const timeoutMs = params.timeoutMs ?? 120_000 + const maxRetries = 3 + const retryDelayMs = 5_000 // wait 5s between retries + + // Resolve the openclaw state dir (~/.openclaw) as CLAWHUB_WORKDIR + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() + ? path.resolve(process.env.OPENCLAW_STATE_DIR.trim().replace(/^~(?=$|[\\/])/, os.homedir())) + : path.join(os.homedir(), '.openclaw') + + // Use bundled npm to run `npx --yes clawhub install ` + const npmBin = getBundledNpmBin() + const npxBin = npmBin.replace(/npm(\.cmd)?$/, 'npx$1') + + const runInstall = (): Promise<{ ok: boolean; error?: string }> => + new Promise((resolve) => { + const child = spawn(npxBin, ['--yes', 'clawhub', 'install', slug], { + cwd: stateDir, + env: { + ...process.env, + CLAWHUB_WORKDIR: stateDir, + CI: 'true', + FORCE_COLOR: '0', + }, + windowsHide: true, + shell: process.platform === 'win32', + }) + + let stdout = '' + let stderr = '' + + child.stdout?.on('data', (d) => { stdout += d.toString() }) + child.stderr?.on('data', (d) => { stderr += d.toString() }) + + const timer = setTimeout(() => { + child.kill() + resolve({ ok: false, error: `Install timed out after ${timeoutMs / 1000}s` }) + }, timeoutMs) + + child.on('error', (err) => { + clearTimeout(timer) + resolve({ ok: false, error: err.message }) + }) + + child.on('close', (code) => { + clearTimeout(timer) + if (code === 0) { + resolve({ ok: true }) + } else { + const errMsg = stderr.trim() || stdout.trim() || `Exit code ${code}` + resolve({ ok: false, error: errMsg }) + } + }) + }) + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const result = await runInstall() + if (result.ok) return result + + // Retry on rate-limit errors + const isRateLimit = result.error?.toLowerCase().includes('rate limit') + if (!isRateLimit || attempt >= maxRetries) return result + + // Wait before retrying + await new Promise((r) => setTimeout(r, retryDelayMs)) + } + + return { ok: false, error: 'Install failed after retries' } + }) + // Get per-agent skill allowlist from openclaw.json // Returns: { ok: true, skills: string[] | null } // null = all skills enabled (no custom allowlist) diff --git a/src/preload/index.ts b/src/preload/index.ts index bc43a50..33296f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -118,6 +118,12 @@ const ipcApi = { ipcRenderer.invoke('agents:memory:list', params), agentsMemoryGet: (params: { agentId: string; name: string }) => ipcRenderer.invoke('agents:memory:get', params), + agentsWorkspaceTree: (params: { agentId: string }) => + ipcRenderer.invoke('agents:workspace:tree', params), + agentsWorkspaceRead: (params: { agentId: string; filePath: string }) => + ipcRenderer.invoke('agents:workspace:read', params), + agentsWorkspaceOpen: (params: { agentId: string; filePath: string }) => + ipcRenderer.invoke('agents:workspace:open', params), agentIdentityGet: (params: { agentId?: string; sessionKey?: string }) => ipcRenderer.invoke('agent:identity:get', params), toolsCatalog: (params: { agentId?: string; sessionKey?: string }) => @@ -190,6 +196,16 @@ const ipcApi = { agentSkillsSet: (agentId: string, skills: string[] | null) => ipcRenderer.invoke('openclaw:agent-skills:set', { agentId, skills }), + // ── ClawHub marketplace ───────────────────────────────────────────────── + clawHubSearch: (query: string, limit?: number) => + ipcRenderer.invoke('clawhub:search', { query, limit }), + clawHubExplore: (limit?: number, cursor?: string) => + ipcRenderer.invoke('clawhub:explore', { limit, cursor }), + clawHubInstall: (name: string, installId: string, timeoutMs?: number) => + ipcRenderer.invoke('clawhub:install', { name, installId, timeoutMs }), + clawHubCacheClear: () => + ipcRenderer.invoke('clawhub:cache-clear'), + // ── Extract progress (main → renderer) ──────────────────────────────────── onExtractProgress: (callback: ExtractProgressCallback) => { extractProgressCallbacks.add(callback) diff --git a/src/renderer/src/components/chat/conversation-list.tsx b/src/renderer/src/components/chat/conversation-list.tsx index 566639b..f030f74 100644 --- a/src/renderer/src/components/chat/conversation-list.tsx +++ b/src/renderer/src/components/chat/conversation-list.tsx @@ -1,6 +1,6 @@ -import { Pin, Plus, Search, Trash2 } from "lucide-react" +import { Loader2, Pin, Plus, RefreshCw, Search, Trash2 } from "lucide-react" import { useState } from "react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" @@ -27,18 +27,29 @@ import { import { GroupAvatar } from "./group-avatar" import { NewConversationDialog } from "./new-conversation-dialog" import { CreateGroupDialog } from "./create-group-dialog" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import type { Conversation } from "@/types" interface ConversationListProps {} export function ConversationList(_props: ConversationListProps) { - const { state, dispatch } = useApp() + const { state, dispatch, refreshFleet } = useApp() const { t } = useI18n() const [search, setSearch] = useState("") const [newConvOpen, setNewConvOpen] = useState(false) const [groupDialogOpen, setGroupDialogOpen] = useState(false) + const [refreshing, setRefreshing] = useState(false) useAvatarVersion() // re-render when avatar changes + const handleRefresh = async () => { + setRefreshing(true) + try { + await refreshFleet() + } finally { + setRefreshing(false) + } + } + const filtered = state.conversations.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()) ) @@ -73,7 +84,23 @@ export function ConversationList(_props: ConversationListProps) { onChange={(e) => setSearch(e.target.value)} /> -
+
+ + + } + > + + + {t("common.refresh")} + + {/* 已连接但 agent 列表为空:加载中 */} + {filtered.length === 0 && state.agents.length === 0 && ( +
+ +

{t("conversationList.loadingAgents")}

+
+ )} + {/* 置顶区域 - 网格头像样式 */} {pinnedConvs.length > 0 && (
diff --git a/src/renderer/src/components/chat/workspace-panel.tsx b/src/renderer/src/components/chat/workspace-panel.tsx index 0878247..f0f12f2 100644 --- a/src/renderer/src/components/chat/workspace-panel.tsx +++ b/src/renderer/src/components/chat/workspace-panel.tsx @@ -2,10 +2,13 @@ import { useCallback, useEffect, useState } from "react" import { ChevronDown, ChevronRight, + ExternalLink, FileText, + FolderClosed, FolderOpen, Loader2, PanelRightClose, + RefreshCw, Save, } from "lucide-react" import { toast } from "sonner" @@ -15,29 +18,34 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" import { useI18n } from "@/i18n" +import { AGENT_FILE_NAMES } from "@/lib/agents/agentFiles" // ── Types ──────────────────────────────────────────────────────────────────── -type BootstrapFile = { +type TreeNode = { name: string + type: "file" | "dir" path: string - missing: boolean size?: number updatedAtMs?: number + children?: TreeNode[] } -type MemoryFile = { +type SelectedFile = { + path: string name: string - size: number - updatedAtMs: number + isBootstrap: boolean } -type SelectedFile = - | { source: "bootstrap"; name: string } - | { source: "memory"; name: string } - // ── Helpers ────────────────────────────────────────────────────────────────── +const BOOTSTRAP_SET = new Set(AGENT_FILE_NAMES) + +function isBootstrapFile(node: TreeNode): boolean { + // Root-level files matching bootstrap names + return node.type === "file" && !node.path.includes("/") && BOOTSTRAP_SET.has(node.name) +} + function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` @@ -57,13 +65,12 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr // data const [workspace, setWorkspace] = useState("") - const [bootstrapFiles, setBootstrapFiles] = useState([]) - const [memoryFiles, setMemoryFiles] = useState([]) + const [tree, setTree] = useState([]) const [loadingList, setLoadingList] = useState(false) - // tree state - const [memoryExpanded, setMemoryExpanded] = useState(false) + // selection const [selected, setSelected] = useState(null) + const [expandedDirs, setExpandedDirs] = useState>(new Set()) // preview state const [content, setContent] = useState("") @@ -71,27 +78,18 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr const [loadingFile, setLoadingFile] = useState(false) const [saving, setSaving] = useState(false) const [dirty, setDirty] = useState(false) + const [fileError, setFileError] = useState(null) - const isMemoryFile = selected?.source === "memory" - const selectedName = selected?.name ?? "" - - // ── Load file list ────────────────────────────────────────────────────── + // ── Load file tree ────────────────────────────────────────────────────── const loadFiles = useCallback(async () => { if (!agentId) return setLoadingList(true) try { - const [filesRes, memRes] = await Promise.all([ - window.ipc.agentsFilesList({ agentId }), - window.ipc.agentsMemoryList({ agentId }), - ]) - if (filesRes.ok) { - const r = filesRes.result as { workspace?: string; files?: BootstrapFile[] } - setWorkspace(r.workspace ?? "") - setBootstrapFiles(r.files ?? []) - } - if (memRes.ok) { - setMemoryFiles((memRes as { ok: true; files: MemoryFile[] }).files ?? []) + const res = await window.ipc.agentsWorkspaceTree({ agentId }) + if (res.ok) { + setWorkspace(res.workspace ?? "") + setTree(res.tree ?? []) } } finally { setLoadingList(false) @@ -104,6 +102,7 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr setContent("") setEditContent("") setDirty(false) + setFileError(null) loadFiles() } }, [open, agentId, loadFiles]) @@ -114,8 +113,10 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr async (file: SelectedFile) => { setLoadingFile(true) setDirty(false) + setFileError(null) try { - if (file.source === "bootstrap") { + if (file.isBootstrap) { + // Bootstrap files: use gateway API (supports editing) const res = await window.ipc.agentsFilesGet({ agentId, name: file.name }) if (res.ok) { const c = (res.result as { file?: { content?: string } })?.file?.content ?? "" @@ -126,21 +127,33 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr setEditContent("") } } else { - const res = await window.ipc.agentsMemoryGet({ agentId, name: file.name }) + // Other files: use filesystem read (read-only) + const res = await window.ipc.agentsWorkspaceRead({ agentId, filePath: file.path }) if (res.ok) { - const c = (res as { ok: true; content?: string }).content ?? "" - setContent(c) - setEditContent(c) + if (res.binary) { + setContent("") + setEditContent("") + setFileError(t("workspace.binaryFile")) + } else if (res.tooLarge) { + setContent("") + setEditContent("") + setFileError(t("workspace.fileTooLarge")) + } else { + const c = res.content ?? "" + setContent(c) + setEditContent(c) + } } else { setContent("") setEditContent("") + setFileError(res.error ?? "") } } } finally { setLoadingFile(false) } }, - [agentId] + [agentId, t] ) const handleSelectFile = (file: SelectedFile) => { @@ -148,10 +161,22 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr loadFileContent(file) } + const toggleDir = (dirPath: string) => { + setExpandedDirs((prev) => { + const next = new Set(prev) + if (next.has(dirPath)) { + next.delete(dirPath) + } else { + next.add(dirPath) + } + return next + }) + } + // ── Save ──────────────────────────────────────────────────────────────── const handleSave = async () => { - if (!selected || selected.source !== "bootstrap") return + if (!selected || !selected.isBootstrap) return setSaving(true) try { const res = await window.ipc.agentsFilesSet({ @@ -172,13 +197,90 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr } } + // ── Render tree recursively ───────────────────────────────────────────── + + const renderTree = (nodes: TreeNode[], depth: number) => + nodes.map((node) => { + if (node.type === "dir") { + const isExpanded = expandedDirs.has(node.path) + return ( +
+ + {isExpanded && node.children && renderTree(node.children, depth + 1)} +
+ ) + } + + const bootstrap = isBootstrapFile(node) + const isSelected = + selected?.path === node.path + return ( +
+ + {node.size != null && ( + + {formatSize(node.size)} + + )} + +
+ ) + }) + return (
- {/* Header — Windows 下右侧留出窗口控件的空间,避免关闭按钮和系统按钮重叠 */} + {/* Header */}
{t("workspace.title")}
- +
+ + +
{/* Workspace path */} @@ -215,61 +329,15 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
) : (
- {/* File list — scrollable, max ~40% height */} + {/* File tree — scrollable, max ~40% height */}
- {/* Bootstrap files */} - {bootstrapFiles.map((file) => ( - - handleSelectFile({ source: "bootstrap", name: file.name }) - } - /> - ))} - - {/* Memory folder */} - {memoryFiles.length > 0 && ( - <> - - {memoryExpanded && - memoryFiles.map((file) => ( - - handleSelectFile({ source: "memory", name: file.name }) - } - /> - ))} - + {tree.length > 0 ? ( + renderTree(tree, 0) + ) : ( +

+ {t("workspace.emptyFile")} +

)}
@@ -282,10 +350,10 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
- {isMemoryFile ? `memory/${selectedName}` : selectedName} + {selected.path}
- {!isMemoryFile && ( + {selected.isBootstrap ? (