docs/plan/2026-06-25-workspace-file-browser-design.md
Rama main · réplica de lectura del repositorio original.
# 工作区文件浏览器设计文档
> 版本:v3.0 | 日期:2026-06-25 | 状态:待开发
## 1. 需求概述
### 1.1 功能目标
在桌面客户端中集成工作区文件浏览器,提供目录树浏览和文件内容预览能力。目录树以右侧面板 Tab 形式呈现,文件内容预览以页签形式集成到中心区域,与对话页签并列切换。
### 1.2 核心需求
| 项目 | 描述 |
|------|------|
| 目录树 | 右侧面板新增「文件树」tab,与现有「工作区+进度」tab 并列,懒加载展开 |
| 文件预览 | 中心区域新增页签,支持同时打开多个文件 tab |
| 状态保全 | 对话和各文件 tab 独立保全状态,切换不影响 Agent 输出 |
| 排除规则 | 跳过 `.git`、`node_modules`、`__pycache__` 等,与 `list-workspace-files` 保持一致 |
| 权限 | 所有登录用户可用 |
### 1.3 V1 范围
**包含:**
- 右侧面板改造:现有 TaskInspector 原地改为 Tab 化面板,包含「工作区+进度」和「文件树」两个 tab
- 目录树:懒加载展开、文件图标、点击文件在中心区域打开预览 tab
- 中心区域页签系统:对话 tab(固定)+ 文件 tab(动态,可多个,无数量上限)
- 文件内容只读预览(带行号)
- 文件 tab 可关闭
- 状态保全(Vue keep-alive)
- 右侧面板宽度可拖拽调整(已有能力)
**不含(V2):**
- 文件搜索(集成 glob/grep)
- 文件创建/编辑/删除
- 右键菜单(在资源管理器中打开、在终端中打开等)
- 文件变更高亮(与 Agent 操作联动)
- 语法高亮(V1 使用纯文本 + 行号)
- CLOUD 模式支持
- 文件 tab 拖拽排序
- 对话 tab 未读消息角标
## 2. 现状分析
### 2.1 已有能力
| 能力 | 位置 | 说明 |
|------|------|------|
| `list-workspace-files` IPC | `main.cjs:328` | 递归遍历返回文件列表(flat,无目录项),用于 `@` 文件引用 |
| `localReadFile` IPC | `preload.cjs` | 读取单个文件内容,支持 offset/limit 分页,返回 `{ content, total_lines }` |
| `resolveWorkspacePath()` | `main.cjs:90` | 相对路径 → 绝对路径解析 |
| `workspace` ref | `useChat.ts:77` | 当前会话的工作区路径,响应式 |
| 右侧面板模式 | `TaskInspector.vue` | 右侧可折叠面板,已有成熟的开关和动画逻辑,需改造为 Tab 化 |
| `usePanelLayout` composable | `composables/usePanelLayout.ts` | 面板折叠状态管理 |
### 2.2 缺失能力
| 缺失项 | 影响 |
|--------|------|
| 目录单层列表 IPC | 现有 `list-workspace-files` 只返回文件,不返回目录项,且是 flat 递归 |
| 文件树组件 | 无任何 Tree 相关组件 |
| 中心区域页签系统 | 当前 TaskView 中心区域是单一聊天视图,无 tab 切换机制 |
| 文件内容预览组件 | 无独立的代码/文本预览组件 |
| TaskInspector Tab 系统 | 现有 TaskInspector 是单一内容面板,无 tab 切换机制 |
## 3. 功能设计
### 3.1 整体布局
```
┌──────────────────────────────────────────────────────────────┐
│ TopNav │
├──────────────────────────────────────────────────────────────┤
│ │ │ │
│ Left │ ┌───────────────────────────────────┐ │ 右侧面板│
│ Panel │ │ [对话] [main.ts] [App.vue] [×] │ │ ┌─────┐ │
│ │ ├───────────────────────────────────┤ │ │工作区│ │
│ │ │ │ │ │文件树│ │ ← Tab 切换
│ │ │ 当前 tab 对应的内容区域 │ │ ├─────┤ │
│ │ │ (对话 或 文件预览) │ │ │ │ │
│ │ │ │ │ │ 内容 │ │
│ │ │ │ │ │ 区域 │ │
│ │ └───────────────────────────────────┘ │ └─────┘ │
├──────────────────────────────────────────────────────────────┤
│ TerminalPanel (底部) │
└──────────────────────────────────────────────────────────────┘
```
**核心变化**:
1. 中心区域(原 TaskView 的聊天区)从单一视图变为 **TabContainer**,包含一个固定「对话」tab 和若干动态「文件」tab。
2. 右侧面板(原 TaskInspector)原地改造为 **Tab 化面板**,包含「工作区+进度」和「文件树」两个 tab。
### 3.2 入口
**切换到文件树 tab**:
- 右侧面板顶部 tab 栏点击「文件树」
**打开文件预览 tab**:
- 在目录树中点击文件节点 → 在中心区域打开或激活对应的文件 tab
### 3.3 Tab 系统
#### 3.3.1 Tab 类型
| 类型 | 标题 | 关闭 | 保全 | 说明 |
|------|------|------|------|------|
| `chat` | 「对话」 | 不可关闭 | 是 | 固定 tab,始终存在 |
| `file` | 文件名 | 可关闭(×) | 是 | 动态 tab,点击目录树文件节点打开 |
#### 3.3.2 Tab 行为
- **打开文件**:点击目录树中的文件节点
- 若该文件已有 tab → 切换到该 tab(不重复打开)
- 若该文件无 tab → 新建 tab 并激活
- **关闭文件**:点击 tab 上的 × 按钮
- 关闭当前激活的 tab → 自动切换到左侧相邻 tab
- 关闭最后一个文件 tab → 自动切换到「对话」tab
- **切换**:点击 tab 标题切换
### 3.4 右侧面板改造
#### 3.4.1 面板结构
现有 TaskInspector 改造为 Tab 化面板,顶部新增 tab 栏切换不同内容:
```
┌──────────────────────────┐
│ [工作区+进度] [文件树] │ ← Tab 栏
├──────────────────────────┤
│ 📁 src/ │ ← 文件树 tab 内容
│ 📁 components/ │
│ 📄 App.vue │
│ 📄 main.ts │
│ 📁 backend/ │
│ 📄 pom.xml │
│ 📄 package.json │
│ 📄 README.md │
│ │
│ [刷新] │ ← 底部操作栏
└──────────────────────────┘
```
**面板宽度**:默认 280px,可拖拽调整,范围 240px ~ 480px。
#### 3.4.2 Tab 行为
| Tab | 内容 | 说明 |
|-----|------|------|
| 工作区+进度 | 现有 TaskInspector 的内容 | 保持原有功能不变 |
| 文件树 | 目录树浏览 | 新增功能 |
#### 3.4.3 节点类型与图标
| 类型 | 图标 | 说明 |
|------|------|------|
| 文件夹(折叠) | `Folder` | 点击展开 |
| 文件夹(展开) | `FolderOpened` | 点击折叠 |
| 文件 | 按扩展名区分 | 点击打开预览 tab |
#### 3.4.4 文件图标映射(基于扩展名)
| 扩展名 | 图标 |
|--------|------|
| `.ts` `.js` `.vue` `.jsx` `.tsx` | `Document`(代码类) |
| `.md` `.txt` `.log` | `Document`(文本类) |
| `.json` `.yaml` `.yml` `.toml` | `Document`(配置类) |
| `.png` `.jpg` `.gif` `.svg` | `Picture` |
| 其他 | `Document`(默认) |
#### 3.4.5 展开行为
- 点击文件夹 → 调用 `listDirectory(path)` → 返回该目录的直接子项
- 子项按字母排序,文件夹在前、文件在后
- 已展开的文件夹再次点击则折叠(保留已加载数据,再次展开无需请求)
- **刷新**:保留已展开的目录状态,遍历 `expandedPaths` 集合,并行请求所有已展开路径的内容并更新对应节点的 children
#### 3.4.6 排除规则
与现有 `list-workspace-files` IPC handler 的排除规则完全一致,包括:
- 隐藏文件/目录(以 `.` 开头)
- `node_modules`、`__pycache__`、`.git`、`target`、`dist`、`build`、`.next`、`.nuxt`、`.venv`、`venv`、`.idea`、`.vscode`
**符号链接**:目录树中展示符号链接节点(带特殊图标标记),但点击无响应,不可展开也不可打开预览。
### 3.5 文件内容预览 Tab
#### 3.5.1 展示
- tab 标题显示文件名(如 `main.ts`)
- 内容区顶部显示:相对路径 + 文件大小
- 带行号的纯文本展示
- 只读,不可编辑
#### 3.5.2 加载与状态
| 状态 | UI 表现 |
|------|---------|
| 加载中 | Element Plus `v-loading` 指令覆盖内容区 |
| 加载成功 | 显示文件内容 |
| 加载失败(`{ error }` 返回) | 居中显示错误信息 + 重试按钮 |
| 文件为空(`total_lines === 0`) | 居中提示「空文件」 |
#### 3.5.3 限制
| 条件 | 检测时机 | 处理 |
|------|----------|------|
| 文件 > 512KB | `list-directory` 阶段(`size` 字段) | 在目录树中文件名旁标记「大文件」,点击后提示「文件过大,无法预览」 |
| 二进制文件 | `read-file` 阶段(读取后检测前 8KB 的 null 字节) | FileViewer 显示「二进制文件,无法预览」 |
| 文件 > 5000 行 | `read-file` 阶段(利用 `offset`/`limit` 参数只请求前 5000 行) | 仅显示前 5000 行,底部提示「仅显示前 5000 行」 |
#### 3.5.4 复用
调用已有的 `localReadFile` IPC(签名见 4.2 节),利用 `limit: 5000` 参数限制传输量,无需新增后端接口。
#### 3.5.5 错误处理
| 场景 | 处理方式 |
|------|----------|
| `list-directory` 失败(权限不足、目录不存在) | 目录树节点显示错误提示,提供重试按钮 |
| `localReadFile` 返回 `{ error }` | FileViewer 显示错误提示:「文件读取失败:{error 字段内容}」 |
| workspace 路径为空(新建 LOCAL 会话未选目录) | 文件树 tab 禁用并提示「请先选择工作区目录」 |
| 文件在预览期间被删除/移动 | tab 保留但显示「文件已不存在」提示 |
### 3.6 状态保全
使用 Vue `<keep-alive>` 包裹 tab 内容区域:
- **对话 tab**:保全聊天消息列表的滚动位置、输入框内容、工具审批队列等
- **文件 tab**:保全文件内容的滚动位置
- **Agent 输出不受影响**:对话 tab 即使被 keep-alive 缓存,WebSocket 消息仍在后台写入 session store,切换回来时自动渲染
#### 3.6.1 useChat 在 keep-alive 下的行为
`useChat` 实例放在 ChatPanel 内部,随 ChatPanel 一起被 keep-alive 缓存。需要确保以下机制在缓存期间正常工作:
| 机制 | 保障方案 |
|------|----------|
| WebSocket 监听 | `useStreamWS` 是全局单例,不依赖组件生命周期,消息持续写入 session store |
| `watch` 副作用 | ChatPanel 内的 watch 通过 `{ detached: true }` 或在 `onActivated` 中重新注册 |
| 定时器(心跳等) | `useChat` 内的定时器在 `onDeactivated` 中暂停,`onActivated` 中恢复 |
| 消息更新 | 通过 `sessionStore` 的响应式数据驱动,`computed` 在组件激活时自动重算 |
### 3.7 workspace 来源
文件浏览器的根目录从 `sessionStore` 的当前 session 中获取:
```typescript
// sessionStore 中已有 workspace 字段
const activeSession = computed(() => sessionStore.activeSession)
const workspace = computed(() => activeSession.value?.workspace ?? '')
```
- LOCAL 模式:`workspace` 为本地文件系统绝对路径
- CLOUD 模式:`workspace` 为空或 undefined,此时文件树 tab 隐藏
### 3.8 会话切换
不同会话可能对应不同工作区,文件 tab 需要**按 sessionId 隔离存储**,与现有 `sessionMessages` / `sessionTodos` 模式一致。
#### 3.8.1 数据模型
在 `useCenterTabs` 中维护一个按 sessionId 索引的 Map:
```typescript
// 每个会话独立的 tab 状态
interface SessionTabState {
tabs: Tab[] // 文件 tab 列表(不含对话 tab,对话 tab 始终存在)
activeTabId: string // 当前激活的 tab id
}
// 全局状态
const sessionTabsMap = ref<Map<string, SessionTabState>>(new Map())
```
#### 3.8.2 切换行为
| 事件 | 行为 |
|------|------|
| 切换到会话 B | 保存当前会话的 tabs + activeTabId 到 Map;从 Map 恢复会话 B 的状态(首次则初始化为空) |
| 切回会话 A | 从 Map 恢复 A 的 tabs + activeTabId |
| 关闭会话 A | 从 Map 删除 A 的 tab 状态 |
| 会话工作区变更 | 清除该会话的文件 tabs(工作区变了,旧路径失效) |
#### 3.8.3 右侧面板联动
- 切换会话时,文件树重新加载为新会话的工作区根目录
- 若新会话是 CLOUD 模式,文件树 tab 隐藏,仅显示「工作区+进度」tab,已打开的文件 tabs 保留但标记为不可用
#### 3.8.4 与现有 keep-alive 的关系
`<keep-alive>` 的缓存 key 需要包含 sessionId,确保不同会话的 ChatPanel / FileViewer 实例互不干扰:
```vue
<keep-alive :max="20">
<component :is="activeComponent" :key="`${activeSessionId}-${activeTabId}`" />
</keep-alive>
```
- 使用 `key` 而非 `include` 来区分实例,因为文件 tab 有多个同组件实例
- `max` 设为 20,防止无限缓存导致内存膨胀
## 4. 架构设计
### 4.1 整体架构
```
┌─────────────────────────────────────────────────────────┐
│ Renderer (Vue 3) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Layout.vue │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ TaskView │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ CenterTabBar ← 新增 │ │ │ │
│ │ │ │ [对话] [file1.ts] [file2.ts] [×] │ │ │ │
│ │ │ ├───────────────────────────────────────┤ │ │ │
│ │ │ │ <keep-alive> │ │ │ │
│ │ │ │ ChatPanel (对话, 含 useChat) │ │ │ │
│ │ │ │ FileViewer (文件预览, 每个tab独立) │ │ │ │
│ │ │ │ </keep-alive> │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ TaskInspector (改造为 Tab 化) │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ [工作区+进度] [文件树] ← Tab 栏 │ │ │ │
│ │ │ ├───────────────────────────────────────┤ │ │ │
│ │ │ │ 原 TaskInspector 内容 (tab 1) │ │ │ │
│ │ │ │ FileTree (目录树, tab 2) ← 新增 │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │ window.electronAPI │
│ ▼ IPC │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Preload Bridge (contextBridge) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Main Process │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ list-directory handler ← 新增 │ │
│ │ 返回 { name, path, isDirectory, size, mtime } │ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ read-file handler(已有,复用) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 4.2 IPC 接口
**新增:`list-directory`**
```
请求:{ dirPath: string, workspace: string }
dirPath — 绝对路径,由 renderer 端基于 workspace 拼接后传入
workspace — 工作区根目录绝对路径,用于沙箱校验
响应:Array<{
name: string,
path: string, // 相对路径(相对于 workspace 根目录)
isDirectory: boolean,
size: number, // 文件大小(目录为 0)
mtime: number, // 最后修改时间戳
isSymlink: boolean // 是否为符号链接
}>
```
实现要点:
- 使用 `fs.readdirSync(dirPath, { withFileTypes: true })` 获取直接子项(非递归)
- 通过 `entry.isSymbolicLink()` 检测符号链接
- 应用排除规则(与 `list-workspace-files` 一致)
- 排序:文件夹在前按字母序,文件在后按字母序
- 路径沙箱:`path.resolve(dirPath)` 必须以 `path.resolve(workspace)` 为前缀,防止目录遍历攻击
- 单次返回最多 500 条,超出时返回截断标记
**已有复用:`local-read-file`**
```
请求:{ path: string, offset?: number, limit?: number }
path — 绝对路径
offset — 起始行号(0-based),默认 0
limit — 最大行数,默认无限制
成功:{ content: string, total_lines: number }
失败:{ error: string } // 不抛异常,通过 error 字段返回
```
### 4.3 文件结构
```
desktop/src/
├── components/
│ ├── file-browser/
│ │ ├── FileTree.vue # 目录树组件
│ │ └── FileTreeNode.vue # 树节点(递归)
│ ├── center/
│ │ ├── CenterTabBar.vue # 页签栏(对话 + 文件 tabs)
│ │ ├── CenterTabContainer.vue # keep-alive 容器,切换 tab 内容
│ │ └── FileViewer.vue # 文件内容预览
│ └── task/
│ └── TaskInspector.vue # 改造:新增 Tab 栏,集成文件树
├── composables/
│ ├── useFileBrowser.ts # 目录树状态
│ └── useCenterTabs.ts # tab 系统状态管理
└── types/
└── file-browser.ts # 类型定义
```
### 4.4 状态管理
#### 4.4.1 `useFileBrowser.ts` — 目录树状态
```typescript
interface FileNode {
name: string
path: string // 相对于工作区的路径
isDirectory: boolean
isSymlink?: boolean // 符号链接标记
size?: number
children?: FileNode[] // 懒加载,展开后填充
expanded?: boolean
}
// workspace 从 TaskInspector props 传入(已通过 useChat → TaskView → props 链路)
export function useFileBrowser(workspace: Ref<string>): UseFileBrowser {
// ...
}
interface UseFileBrowser {
treeData: Ref<FileNode[]>
loading: Ref<boolean>
expandedPaths: Ref<Set<string>> // 已展开的目录路径集合,用于刷新时保留状态
expandDir: (node: FileNode) => Promise<void>
collapseDir: (node: FileNode) => void
refresh: () => Promise<void> // 并行请求 expandedPaths 中所有路径,更新对应节点
}
```
#### 4.4.2 `useCenterTabs.ts` — Tab 系统
```typescript
interface Tab {
id: string // chat 固定为 'chat',文件为相对路径
type: 'chat' | 'file'
title: string // 显示标题
filePath?: string // 文件 tab 的绝对路径
}
interface UseCenterTabs {
tabs: Ref<Tab[]> // 当前会话的 tabs(从 sessionTabsMap 中读取)
activeTabId: Ref<string> // 当前激活的 tab id
openFileTab: (filePath: string, title: string) => void
closeTab: (tabId: string) => void
activateTab: (tabId: string) => void
}
```
内部维护 `sessionTabsMap: Map<string, SessionTabState>`,通过 watch `activeSessionId` 自动切换当前会话的 tab 状态。
## 5. 实现步骤
### Step 1: Main Process — `list-directory` handler
在 `main.cjs` 中新增 IPC handler,约 30 行:
- `fs.readdirSync` + `withFileTypes`
- 排除规则过滤
- 路径沙箱校验
- 排序输出
### Step 2: Preload Bridge
在 `preload.cjs` 中暴露 `listDirectory(dirPath, workspace)` 方法,在 `electron.d.ts` 中补充类型。
### Step 3: Composable — `useFileBrowser.ts`
目录树状态管理:treeData、加载、展开/折叠、刷新。
### Step 4: Composable — `useCenterTabs.ts`
Tab 系统状态管理:tabs 列表、activeTab、openFileTab、closeTab。
### Step 5: 组件 — FileTree + FileTreeNode
- `FileTreeNode.vue`:递归树节点,点击展开/折叠目录或触发 `openFileTab`
- `FileTree.vue`:树容器,接收根节点数据
### Step 6: 组件 — CenterTabBar + CenterTabContainer
- `CenterTabBar.vue`:tab 标题栏,显示 tab 列表、关闭按钮
- `CenterTabContainer.vue`:`<keep-alive>` 容器,根据 activeTabId 切换渲染内容
### Step 7: 组件 — FileViewer
- 文件内容展示(行号 + 内容 + 元信息)
- 每个文件 tab 实例独立,通过 keep-alive 保全滚动位置
### Step 8: 重构 TaskView
**ChatPanel 移入范围**:将以下组件从 TaskView 移入 ChatPanel:
- 消息列表(MessageBubble v-for)
- FileChangePanel
- QueuePanel
- ApprovalStack
- QuestionPanel
- ChatInput
**依赖传递方案**:TaskView 通过 `provide` 注入 `agentId`、`executionMode`、`newTaskModelId`、`permissionLevel` 等 Ref,ChatPanel 和 TaskInspector 通过 `inject` 获取。避免 props 层层传递。
**useChat 归属**:`useChat` 实例在 ChatPanel 内部创建,通过 inject 获取所需的 Ref 参数。
**集成**:在 TaskView 中引入 `CenterTabContainer`,ChatPanel 作为默认 tab。
### Step 9: 改造 TaskInspector
- 在 TaskInspector 内部新增 Tab 栏:「工作区+进度」+「文件树」
- 将现有 TaskInspector 内容归入「工作区+进度」tab
- 集成 `FileTree` 组件为「文件树」tab 内容
## 6. 风险与限制
| 风险 | 说明 | 缓解措施 |
|------|------|----------|
| keep-alive 内存 | 多个文件 tab 同时保活可能占用较多内存 | 文件内容为纯文本,单文件内存占用有限;若出现性能问题,后续可引入 LRU 淘汰策略 |
| 大文件预览 | 单个大文件渲染大量 DOM 行 | 5000 行上限 + 虚拟滚动(V2) |
| 大目录性能 | 某些目录可能包含数千个子项 | 限制单次返回最多 500 条,超出提示用户使用搜索 |
| CLOUD 模式 | 服务端无 `list_directory` 工具 | V1 仅支持 LOCAL 模式,CLOUD 模式下隐藏文件树 tab |
| useChat 缓存副作用 | ChatPanel 被 keep-alive 缓存后 watch/定时器可能异常 | 参见 3.6.1 节保障方案,关键机制与组件生命周期解耦 |
| 右侧面板改造范围 | TaskInspector 改造为 Tab 化涉及现有逻辑迁移 | Step 9 中详细规划迁移边界,确保原有功能不受影响 |