文件菜单显示

这是一个非常专业的需求。要将一个涉及 Main/Preload/Renderer 三端的复杂业务封装成一个“开箱即用”的库,我们需要遵循 “关注点分离”“依赖注入” 的原则。

为了满足“封装成库”且“参考 VS Code”的要求,我设计了一个名为 electron-tree-explorer 的本地库结构。你可以直接将其视为一个独立的 npm 包来维护。

📚 库的设计架构

这个库将包含三个导出入口:

  1. main: 提供给 Electron 主进程注册 IPC 监听器。
  2. preload: 提供给 Preload 脚本暴露 API。
  3. renderer: 提供 Vue 组件 (FileExplorer) 和 CSS。

🚀 1. 核心类型定义 (types.ts)

首先定义通信协议,保证前后端类型一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// packages/electron-tree-explorer/src/types.ts

export interface FileNode {
name: string
path: string
type: 'file' | 'directory'
children?: FileNode[] // 如果已加载,则有子节点
isExpanded?: boolean // UI状态
isEditing?: boolean // 是否正在重命名/新建
}

export type ExplorerEvents =
| 'ete:read-dir'
| 'ete:create'
| 'ete:rename'
| 'ete:delete'
| 'ete:open-file'

export interface CreatePayload {
basePath: string
name: string
type: 'file' | 'directory'
}

🏗️ 2. 主进程逻辑 (main.ts)

这里封装了所有 fs 操作。为了安全,我们不直接暴露 fs,而是暴露业务方法。

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
// packages/electron-tree-explorer/src/main.ts
import { ipcMain, shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import { FileNode, CreatePayload } from './types'

export class FileExplorerServer {
constructor() {
this.registerHandlers()
}

private registerHandlers() {
// 1. 读取目录 (懒加载模式)
ipcMain.handle('ete:read-dir', async (_, dirPath: string): Promise<FileNode[]> => {
try {
const names = await fs.readdir(dirPath)
const nodes: FileNode[] = []

for (const name of names) {
// 过滤隐藏文件
if (name.startsWith('.')) continue

const fullPath = path.join(dirPath, name)
try {
const stats = await fs.stat(fullPath)
nodes.push({
name,
path: fullPath,
type: stats.isDirectory() ? 'directory' : 'file',
children: undefined // 懒加载,初始为空
})
} catch (e) { /* ignore errors */ }
}

// 排序:文件夹在前
return nodes.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === 'directory' ? -1 : 1
})
} catch (error) {
console.error('Read dir failed:', error)
return []
}
})

// 2. 创建文件/文件夹
ipcMain.handle('ete:create', async (_, { basePath, name, type }: CreatePayload) => {
const targetPath = path.join(basePath, name)
if (await fs.pathExists(targetPath)) throw new Error('Exist')

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

// 3. 重命名
ipcMain.handle('ete:rename', async (_, { oldPath, newName }) => {
const dir = path.dirname(oldPath)
const newPath = path.join(dir, newName)
await fs.rename(oldPath, newPath)
return newPath
})

// 4. 删除 (移入回收站)
ipcMain.handle('ete:delete', async (_, filePath: string) => {
await shell.trashedItem(filePath)
return true
})

// 5. 打开文件 (使用默认应用)
ipcMain.handle('ete:open-file', async (_, filePath: string) => {
// 如果需要编辑器打开,这里不处理,由前端 emit 事件给主应用
// 这里只演示用系统默认程序打开
shell.openPath(filePath)
})
}
}

// 导出单例初始化方法
export function registerFileExplorer() {
new FileExplorerServer()
}

🌉 3. Preload 桥接 (preload.ts)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// packages/electron-tree-explorer/src/preload.ts
import { ipcRenderer, contextBridge } from 'electron'
import { CreatePayload } from './types'

const api = {
readDir: (path: string) => ipcRenderer.invoke('ete:read-dir', path),
create: (payload: CreatePayload) => ipcRenderer.invoke('ete:create', payload),
rename: (oldPath: string, newName: string) => ipcRenderer.invoke('ete:rename', { oldPath, newName }),
delete: (path: string) => ipcRenderer.invoke('ete:delete', path),
openFile: (path: string) => ipcRenderer.invoke('ete:open-file', path)
}

// 供主项目调用
export function exposeExplorerAPI() {
if (process.contextIsolated) {
contextBridge.exposeInMainWorld('fileExplorerAPI', api)
} else {
// @ts-ignore
window.fileExplorerAPI = api
}
}

🎨 4. Renderer 组件 (Vue)

这里是逻辑最复杂的地方。为了封装性,我们将 ContextMenu 和 TreeItem 都集成在一起。

4.1 树节点组件 (TreeItem.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
<template>
<div class="ete-node">
<!-- 节点行 -->
<div
class="ete-label"
:class="{ 'selected': isSelected }"
:style="{ paddingLeft: `${depth * 12 + 10}px` }"
@click="onLeftClick"
@contextmenu.prevent="onRightClick"
>
<!-- 箭头 -->
<span class="ete-arrow" :class="{ 'open': isOpen, 'hidden': node.type === 'file' }">

</span>

<!-- 图标 -->
<span class="ete-icon">{{ node.type === 'directory' ? '📁' : '📄' }}</span>

<!-- 正常名称 -->
<span v-if="!isRenaming" class="ete-name">{{ node.name }}</span>

<!-- 重命名/新建 输入框 -->
<input
v-else
ref="inputRef"
v-model="editName"
class="ete-input"
@blur="confirmEdit"
@keydown.enter="confirmEdit"
@keydown.esc="cancelEdit"
@click.stop
/>
</div>

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

<script setup lang="ts">
import { ref, nextTick, watch, toRef } from 'vue'
import { FileNode } from './types'

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

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

const isOpen = ref(false)
const isRenaming = ref(false)
const editName = ref('')
const inputRef = ref<HTMLInputElement>()

// 监听外部控制的编辑状态 (用于新建)
watch(() => props.node.isEditing, (val) => {
if (val) startEdit()
})

const isSelected = toRef(() => props.selectedPath === props.node.path)

// 加载子目录
const loadChildren = async () => {
if (props.node.children) return // 已加载过
// @ts-ignore
const children = await window.fileExplorerAPI.readDir(props.node.path)
props.node.children = children
}

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

const onRightClick = (e: MouseEvent) => {
emit('select', props.node) // 右键同时也选中
emit('context', { event: e, node: props.node })
}

// === 编辑逻辑 ===
const startEdit = () => {
isRenaming.value = true
editName.value = props.node.name
nextTick(() => inputRef.value?.focus())
}

const confirmEdit = async () => {
if (!isRenaming.value) return
isRenaming.value = false

if (!editName.value || editName.value === props.node.name) {
if (props.node.name === '') {
// 如果是新建文件且名字为空,触发刷新父级以移除临时节点
emit('refresh', null)
}
return
}

try {
if (props.node.path.endsWith('__temp__')) {
// 新建逻辑
const basePath = props.node.path.replace('__temp__', '')
// @ts-ignore
await window.fileExplorerAPI.create({
basePath,
name: editName.value,
type: props.node.type
})
} else {
// 重命名逻辑
// @ts-ignore
await window.fileExplorerAPI.rename(props.node.path, editName.value)
}
// 成功后请求父级刷新
emit('refresh', 'parent')
} catch (e) {
alert('操作失败: ' + e)
// 失败如果是新建,需要移除
if (props.node.path.endsWith('__temp__')) emit('refresh', null)
}
}

const cancelEdit = () => {
isRenaming.value = false
if (props.node.path.endsWith('__temp__')) emit('refresh', null)
}

defineExpose({ startEdit })
</script>

<style>
/* 包含 VS Code 风格的基础样式 */
.ete-label {
display: flex;
align-items: center;
cursor: pointer;
height: 22px;
color: #cccccc;
font-size: 13px;
user-select: none;
border: 1px solid transparent;
}
.ete-label:hover { background-color: #2a2d2e; }
.ete-label.selected { background-color: #37373d; color: #fff; }

.ete-arrow {
font-size: 10px; margin-right: 4px; transition: transform 0.1s;
color: #999;
width: 14px; text-align: center;
}
.ete-arrow.open { transform: rotate(90deg); }
.ete-arrow.hidden { visibility: hidden; }

.ete-icon { margin-right: 6px; }
.ete-input {
background: #3c3c3c; color: white; border: 1px solid #007fd4;
outline: none; height: 20px; width: 100%;
}
</style>

4.2 主入口组件 (FileExplorer.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
<template>
<div class="ete-container" @click="selectedPath = ''" @contextmenu.prevent="onRootContext">
<!-- 递归树 -->
<TreeItem
v-for="node in tree"
:key="node.path"
ref="itemRefs"
:node="node"
:depth="0"
:selectedPath="selectedPath"
@select="onSelect"
@context="onContext"
@refresh="refreshNode"
/>

<!-- 右键菜单 -->
<div v-if="menu.visible" class="ete-context-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }">
<div @click="handleAction('new_file')">新建文件</div>
<div @click="handleAction('new_folder')">新建文件夹</div>
<div @click="handleAction('rename')">重命名</div>
<div class="ete-divider"></div>
<div @click="handleAction('delete')" class="ete-danger">删除</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, reactive, nextTick } from 'vue'
import TreeItem from './TreeItem.vue'
import { FileNode } from './types'

const props = defineProps<{ rootPath: string }>()
const emit = defineEmits(['open-file'])

const tree = ref<FileNode[]>([])
const selectedPath = ref('')
const itemRefs = ref([]) // 用于获取子组件实例调用 startEdit

const menu = reactive({ visible: false, x: 0, y: 0, targetNode: null as FileNode | null })

// 初始化
onMounted(async () => {
await loadRoot()
})

const loadRoot = async () => {
if (!props.rootPath) return
// @ts-ignore
tree.value = await window.fileExplorerAPI.readDir(props.rootPath)
}

const onSelect = (node: FileNode) => {
selectedPath.value = node.path
if (node.type === 'file') emit('open-file', node.path)
}

const onContext = ({ event, node }: any) => {
menu.visible = true
menu.x = event.clientX
menu.y = event.clientY
menu.targetNode = node
}

const onRootContext = (event: MouseEvent) => {
// 空白处右键
menu.visible = true
menu.x = event.clientX
menu.y = event.clientY
menu.targetNode = null // null 代表根目录
}

// 刷新逻辑:如果是 'parent',说明子节点有变动,这里简单处理,重新加载整个父节点
// 在生产级库中,应该通过 path 找到父节点并只刷新它
const refreshNode = () => {
// 简单实现:全量刷新根目录 (优化点:根据 path 局部刷新)
loadRoot()
}

const handleAction = async (action: string) => {
menu.visible = false
const target = menu.targetNode

if (action === 'delete') {
if (!target) return
if (confirm(`删除 ${target.name}?`)) {
// @ts-ignore
await window.fileExplorerAPI.delete(target.path)
loadRoot()
}
return
}

if (action === 'rename') {
if (!target) return
// 找到对应的组件实例调用 startEdit
// 实际库中一般使用 bus 或 provide/inject 来控制编辑状态
target.isEditing = true
return
}

if (action === 'new_file' || action === 'new_folder') {
// 1. 确定插入位置(当前文件夹下,或者当前文件同级,或者根目录)
let insertList = tree.value
let basePath = props.rootPath

if (target) {
if (target.type === 'directory') {
// 插入到该文件夹内部(需要先展开)
if (!target.children) target.children = []
insertList = target.children
basePath = target.path
} else {
// 插入到同级 (这里简化,暂时只支持根目录或者文件夹内)
// 实际需要递归查找 parent
}
}

// 2. 插入临时节点
const tempNode: FileNode = {
name: '',
path: basePath + '/__temp__', // 特殊标记
type: action === 'new_file' ? 'file' : 'directory',
isEditing: true
}
insertList.unshift(tempNode)
}
}

// 点击空白关闭菜单
window.addEventListener('click', () => { menu.visible = false })
</script>

<style scoped>
.ete-container {
height: 100%; overflow-y: auto; background: #252526; color: #ccc;
font-family: 'Segoe UI', sans-serif;
}
.ete-context-menu {
position: fixed; background: #333; border: 1px solid #454545;
box-shadow: 0 2px 8px rgba(0,0,0,0.3); z-index: 9999;
min-width: 120px; padding: 4px 0;
}
.ete-context-menu div {
padding: 6px 16px; cursor: pointer; font-size: 13px;
}
.ete-context-menu div:hover { background: #094771; color: white; }
.ete-divider { height: 1px; background: #454545; margin: 4px 0; padding: 0 !important; }
.ete-danger:hover { background: #800000 !important; }
</style>

📦 5. 如何在你的项目中使用

现在你已经有了一个名为 electron-tree-explorer 的本地库(文件夹)。

步骤 1: 注册主进程

src/main/index.ts 中:

1
2
3
4
import { registerFileExplorer } from '../packages/electron-tree-explorer/src/main'

// 在 app.whenReady() 之后
registerFileExplorer()

步骤 2: 注册 Preload

src/preload/index.ts 中:

1
2
3
import { exposeExplorerAPI } from '../packages/electron-tree-explorer/src/preload'

exposeExplorerAPI()

步骤 3: 在 Vue 中使用

在你的业务页面 (比如 Sidebar.vue):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="sidebar">
<FileExplorer
root-path="D:/MyProject"
@open-file="handleOpenFile"
/>
</div>
</template>

<script setup lang="ts">
import FileExplorer from '../packages/electron-tree-explorer/src/FileExplorer.vue'

const handleOpenFile = (path: string) => {
console.log('用户请求打开文件:', path)
// 这里调用你自己的 Tab 系统打开文件
}
</script>

✅ 方案亮点

  1. 高度封装:主项目不需要关心 fs.readdir 怎么写,也不需要写 CSS。
  2. 安全:通过 contextBridge 只暴露了 5 个必要的 API,没有暴露整个 fs 模块。
  3. VS Code 体验
    • 懒加载:只有点开文件夹才读取子目录,性能好。
    • 内联重命名:不是弹窗,而是直接在树上变成输入框。
    • 右键菜单:自定义 DOM 菜单,样式统一。
  4. 可扩展:如果你想发包,只需要把 packages/electron-tree-explorertsup 打包,并在 package.json 定义 exports 即可。

如果你的文件目录结构或者 CSS 样式有特殊要求,直接修改这个 packages 目录下的文件即可,不会污染你的业务代码。

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