electron-vite IDE左侧读取工程文件

这是一个非常硬核且实用的需求。要实现类似 VS Code / Visual Studio 的工程文件资源管理器,我们需要打通 Electron 的 Main Process (Node.js fs 能力)Renderer Process (Vue 组件交互)

由于代码量较大,我将分为 后端 (IPC/Node)前端 (Vue 组件) 两部分来构建。

核心功能规划

  1. 工程元数据:在根目录维护一个 project.config.json
  2. 文件树生成:递归读取文件夹结构,过滤 node_modules 等。
  3. CRUD 操作:读取内容、新建文件/文件夹、重命名、删除。
  4. UI 交互:递归树组件 + 右键菜单。

第一部分:后端逻辑 (Node.js + IPC)

我们继续沿用模块化的思路,新建 src/main/ipc/fs-ipc.ts

1. 定义类型与 IPC 逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// src/main/ipc/fs-ipc.ts
import { ipcMain, dialog, BrowserWindow, shell } from 'electron'
import fs from 'fs-extra' // 建议安装 fs-extra: npm install fs-extra @types/fs-extra
import path from 'path'

// 定义文件节点类型
export interface FileNode {
name: string
path: string
type: 'file' | 'directory'
children?: FileNode[]
}

// 忽略列表
const IGNORE_LIST = ['.git', 'node_modules', '.DS_Store']

// 递归构建文件树
const buildFileTree = (dirPath: string): FileNode[] => {
const stats = fs.statSync(dirPath)
if (!stats.isDirectory()) return []

const children = fs.readdirSync(dirPath)
.filter(name => !IGNORE_LIST.includes(name))
.map(name => {
const fullPath = path.join(dirPath, name)
const fileStats = fs.statSync(fullPath)

const node: FileNode = {
name,
path: fullPath,
type: fileStats.isDirectory() ? 'directory' : 'file'
}

if (node.type === 'directory') {
node.children = buildFileTree(fullPath)
}
return node
})

// 排序:文件夹在前,文件在后
return children.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === 'directory' ? -1 : 1
})
}

export function setupFsIPC() {

// 1. 打开工程目录
ipcMain.handle('fs-open-project-dialog', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openDirectory']
})
if (canceled) return null
return filePaths[0]
})

// 2. 读取整个工程结构
ipcMain.handle('fs-read-project-tree', async (_, rootPath: string) => {
try {
if (!fs.existsSync(rootPath)) throw new Error('Path not found')

// 读取或创建工程配置文件
const configPath = path.join(rootPath, 'project.config.json')
let projectInfo = { name: path.basename(rootPath), version: '1.0.0' }

if (fs.existsSync(configPath)) {
projectInfo = await fs.readJson(configPath)
} else {
await fs.writeJson(configPath, projectInfo, { spaces: 2 })
}

const tree = buildFileTree(rootPath)
return { tree, projectInfo }
} catch (error) {
console.error(error)
throw error
}
})

// 3. 读取文件内容
ipcMain.handle('fs-read-file', async (_, filePath: string) => {
return await fs.readFile(filePath, 'utf-8')
})

// 4. 保存文件
ipcMain.handle('fs-save-file', async (_, { filePath, content }) => {
return await fs.writeFile(filePath, content, 'utf-8')
})

// 5. 创建文件或文件夹
ipcMain.handle('fs-create-entry', async (_, { parentPath, name, type }) => {
const fullPath = path.join(parentPath, name)
if (fs.existsSync(fullPath)) throw new Error('File already exists')

if (type === 'directory') {
await fs.ensureDir(fullPath)
} else {
await fs.ensureFile(fullPath)
}
return fullPath
})

// 6. 删除
ipcMain.handle('fs-delete-entry', async (_, filePath: string) => {
// 移入回收站 (比直接删除更安全)
return await shell.trashedItem(filePath)
// 或者直接删除: await fs.remove(filePath)
})
}

记得在 src/main/index.ts 中调用 setupFsIPC()


第二部分:Preload 暴露 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'

const fsAPI = {
openProject: () => ipcRenderer.invoke('fs-open-project-dialog'),
readProjectTree: (path: string) => ipcRenderer.invoke('fs-read-project-tree', path),
readFile: (path: string) => ipcRenderer.invoke('fs-read-file', path),
saveFile: (path: string, content: string) => ipcRenderer.invoke('fs-save-file', { filePath: path, content }),
createEntry: (parentPath: string, name: string, type: 'file' | 'directory') =>
ipcRenderer.invoke('fs-create-entry', { parentPath, name, type }),
deleteEntry: (path: string) => ipcRenderer.invoke('fs-delete-entry', path)
}

if (process.contextIsolated) {
contextBridge.exposeInMainWorld('fsAPI', fsAPI)
} else {
// @ts-ignore
window.fsAPI = fsAPI
}

第三部分:前端 Vue 组件 (递归树 + 右键菜单)

我们将创建两个组件:

  1. FileTreeItem.vue: 递归渲染单个节点。
  2. ProjectExplorer.vue: 容器,处理右键菜单逻辑。

1. 递归子组件 src/components/explorer/FileTreeItem.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<template>
<div class="tree-item">
<!-- 当前行内容 -->
<div
class="item-label"
:class="{ selected: isSelected }"
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
@click.stop="onLeftClick"
@contextmenu.prevent.stop="onRightClick"
>
<!-- 图标 (可用 iconify 或 svg 替换) -->
<span class="icon">{{ node.type === 'directory' ? (isOpen ? '📂' : '📁') : '📄' }}</span>
<span class="name">{{ node.name }}</span>
</div>

<!-- 递归渲染子节点 -->
<div v-if="node.type === 'directory' && isOpen">
<FileTreeItem
v-for="child in node.children"
:key="child.path"
:node="child"
:depth="depth + 1"
:selectedPath="selectedPath"
@select="$emit('select', $event)"
@context-menu="$emit('context-menu', $event)"
/>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
node: any
depth: number
selectedPath: string
}>()

const emit = defineEmits(['select', 'context-menu'])

const isOpen = ref(false)

// 计算是否被选中
const isSelected = computed(() => props.selectedPath === props.node.path)

const onLeftClick = () => {
if (props.node.type === 'directory') {
isOpen.value = !isOpen.value
}
emit('select', props.node)
}

const onRightClick = (event: MouseEvent) => {
// 将事件和当前节点信息向上抛出
emit('context-menu', { event, node: props.node })
}
</script>

<style scoped>
.item-label {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 0;
user-select: none;
color: #ccc;
}
.item-label:hover { background-color: #2a2d2e; }
.item-label.selected { background-color: #37373d; color: white; }
.icon { margin-right: 6px; font-size: 14px; }
.name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

2. 主容器组件 src/components/explorer/ProjectExplorer.vue

这里包含了核心业务逻辑:加载工程、右键菜单、创建/删除文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
<template>
<div class="explorer-container">
<!-- 顶部工具栏 -->
<div class="toolbar">
<span>{{ projectName || '未打开工程' }}</span>
<button @click="openProject" class="icon-btn">📂</button>
<button @click="refreshTree" class="icon-btn" v-if="projectPath">🔄</button>
</div>

<!-- 文件树区域 -->
<div class="tree-content" @contextmenu.prevent="onEmptyAreaRightClick">
<FileTreeItem
v-for="node in fileTree"
:key="node.path"
:node="node"
:depth="0"
:selectedPath="selectedPath"
@select="onFileSelect"
@context-menu="handleContextMenu"
/>
</div>

<!-- 自定义右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
@blur="closeContextMenu"
>
<div class="menu-item disabled">{{ contextMenu.targetName }}</div>
<div class="divider"></div>
<div class="menu-item" @click="createNew('file')">📄 新建文件</div>
<div class="menu-item" @click="createNew('directory')">📁 新建文件夹</div>
<div class="divider"></div>
<div class="menu-item delete" @click="deleteItem">🗑 删除</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import FileTreeItem from './FileTreeItem.vue'

// 状态
const projectPath = ref('')
const projectName = ref('')
const fileTree = ref<any[]>([])
const selectedPath = ref('')

// 右键菜单状态
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
targetNode: null as any | null, // 右键点击的节点
targetName: ''
})

// === 1. 打开工程 ===
const openProject = async () => {
const path = await window.fsAPI.openProject()
if (path) {
projectPath.value = path
await loadTree()
}
}

// === 2. 加载文件树 ===
const loadTree = async () => {
if (!projectPath.value) return
const { tree, projectInfo } = await window.fsAPI.readProjectTree(projectPath.value)
fileTree.value = tree
projectName.value = projectInfo.name
}

const refreshTree = () => loadTree()

// === 3. 文件选择 ===
const onFileSelect = async (node: any) => {
selectedPath.value = node.path
if (node.type === 'file') {
// TODO: 这里可以调用 Dockview 或 EventBus 打开文件编辑器
// const content = await window.fsAPI.readFile(node.path)
console.log('打开文件:', node.path)
}
}

// === 4. 右键菜单处理 ===
const handleContextMenu = ({ event, node }: any) => {
showMenu(event, node)
}

const onEmptyAreaRightClick = (event: MouseEvent) => {
// 在空白处点击,视作在根目录操作
showMenu(event, {
path: projectPath.value,
type: 'directory',
name: '根目录'
})
}

const showMenu = (event: MouseEvent, node: any) => {
contextMenu.visible = true
contextMenu.x = event.clientX
contextMenu.y = event.clientY
contextMenu.targetNode = node
contextMenu.targetName = node.name
}

const closeContextMenu = () => {
contextMenu.visible = false
}

// === 5. 创建文件/文件夹 ===
const createNew = async (type: 'file' | 'directory') => {
closeContextMenu()
if (!contextMenu.targetNode) return

// 确定父目录 (如果是文件,则在同级创建;如果是文件夹,则在内部创建)
const parentPath = contextMenu.targetNode.type === 'directory'
? contextMenu.targetNode.path
: contextMenu.targetNode.path.replace(/[\\/][^\\/]+$/, '') // 获取父级路径

// 简单起见,这里用 prompt,实际可以用 Modal 弹窗
const name = prompt(`请输入${type === 'file' ? '文件' : '文件夹'}名称:`)
if (!name) return

try {
await window.fsAPI.createEntry(parentPath, name, type)
await refreshTree() // 刷新树
} catch (e) {
alert('创建失败: ' + e)
}
}

// === 6. 删除 ===
const deleteItem = async () => {
closeContextMenu()
const node = contextMenu.targetNode
if (!node || node.path === projectPath.value) return

if (!confirm(`确定要删除 ${node.name} 吗?`)) return

try {
await window.fsAPI.deleteEntry(node.path)
await refreshTree()
} catch (e) {
alert('删除失败')
}
}

// 点击外部关闭菜单
const onClickOutside = () => closeContextMenu()
onMounted(() => window.addEventListener('click', onClickOutside))
onUnmounted(() => window.removeEventListener('click', onClickOutside))
</script>

<style scoped>
.explorer-container {
height: 100%;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
color: #ccc;
font-family: 'Segoe UI', sans-serif;
user-select: none;
}
.toolbar {
padding: 8px;
background-color: #252526;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: bold;
}
.icon-btn {
background: none; border: none; cursor: pointer; color: #ccc;
}
.icon-btn:hover { color: white; }
.tree-content {
flex: 1;
overflow-y: auto;
padding-top: 4px;
}

/* 右键菜单样式 */
.context-menu {
position: fixed;
background: #252526;
border: 1px solid #454545;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
min-width: 150px;
z-index: 9999;
border-radius: 4px;
padding: 4px 0;
}
.menu-item {
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
color: #cccccc;
}
.menu-item:hover:not(.disabled) {
background-color: #094771;
color: white;
}
.menu-item.disabled {
color: #666;
cursor: default;
font-size: 12px;
border-bottom: 1px solid #333;
}
.menu-item.delete:hover {
background-color: #710909;
}
.divider {
height: 1px;
background-color: #454545;
margin: 4px 0;
}
</style>

如何集成到你的 Dockview

你可以将 ProjectExplorer.vue 作为一个 Panel 放入 Dockview 的侧边栏(如果你的布局支持侧边栏),或者作为一个普通的 Panel。

关键的联动点:

  1. 文件点击

    • onFileSelect 中,获取到 node.path
    • 调用我们之前实现的 tab-manager -> findAndActivateTab(node.path)
    • 如果没找到,则读取文件内容 (window.fsAPI.readFile(node.path)),然后 dockAPI.addPanel 创建一个新的编辑器 Tab,ID 使用文件路径(确保唯一)。
  2. 右键创建

    • 创建文件成功后,可以自动触发一次“打开文件”的操作,体验会更好。

总结

这个 Demo 实现了:

  1. Node.js 层:安全的 fs 封装,只暴露必要接口。
  2. 数据层:简单的 project.config.json 读写。
  3. UI 层:递归文件树,支持无限层级。
  4. 交互:VS Code 风格的右键菜单,支持新建和删除。

这基本上就是一个轻量级 IDE 资源管理器的雏形了。你可以把 ProjectExplorer.vue 复制进去直接运行。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器