feat(文件树和标签页): 添加统一右键菜单和文件路径处理

- 重构文件树右键菜单为全局统一组件,支持节点和空白区域操作
- 添加 closeFile 方法,支持通过文件路径关闭标签页
- 实现配置文件中绝对路径与相对路径的自动转换
- 删除文件时自动关闭对应的标签页
This commit is contained in:
cfq 2026-01-30 16:29:44 +08:00
parent 49b89d7ea1
commit 4a6810e57b
3 changed files with 170 additions and 75 deletions

View File

@ -23,56 +23,60 @@
<a-empty description="请选择目录" />
</div>
<a-dropdown v-else :trigger="['contextmenu']">
<div class="tree-content" ref="treeContentRef">
<a-directory-tree ref="treeRef" v-model:expandedKeys="state.expandedKeys" v-model:selectedKeys="selectedKeys" :tree-data="treeData" :field-names="{ title: 'name', key: 'path', children: 'children' }" :load-data="onLoadData" @select="onSelect">
<template #icon="{ dataRef, expanded }">
<component :is="getFileIcon(dataRef.name, dataRef.type, expanded)" :style="{ color: getFileIconColor(dataRef.name, dataRef.type), fontSize: '18px', marginRight: '6px' }" />
</template>
<template #title="{ dataRef }">
<a-dropdown :trigger="['contextmenu']">
<span class="tree-node-title" :title="dataRef.name">{{ dataRef.name }}</span>
<template #overlay>
<a-menu @click="(e) => handleMenuClick(e, dataRef)">
<a-menu-item key="open" v-if="dataRef.type === 'file'">打开</a-menu-item>
<div class="tree-content" ref="treeContentRef" @contextmenu.prevent="handleBackgroundMenuClick">
<a-directory-tree ref="treeRef" v-model:expandedKeys="state.expandedKeys" v-model:selectedKeys="selectedKeys" :tree-data="treeData" :field-names="{ title: 'name', key: 'path', children: 'children' }" :load-data="onLoadData" @select="onSelect" @rightClick="onNodeRightClick">
<template #icon="{ dataRef, expanded }">
<component :is="getFileIcon(dataRef.name, dataRef.type, expanded)" :style="{ color: getFileIconColor(dataRef.name, dataRef.type), fontSize: '18px', marginRight: '6px' }" />
</template>
<template #title="{ dataRef }">
<span class="tree-node-title" :title="dataRef.name">{{ dataRef.name }}</span>
</template>
</a-directory-tree>
<template v-if="isMdFile(dataRef.name)">
<a-menu-item key="pin">
{{ isPinned(dataRef.path) ? "取消置顶" : "置顶" }}
</a-menu-item>
</template>
<!-- 全局右键菜单 -->
<a-dropdown :open="contextMenu.visible" :trigger="['contextmenu']" @openChange="(val) => (contextMenu.visible = val)">
<div class="context-menu-anchor" :style="{ position: 'fixed', left: contextMenu.x + 'px', top: contextMenu.y + 'px', width: '1px', height: '1px' }"></div>
<template #overlay>
<a-menu @click="handleGlobalMenuClick">
<!-- 文件/文件夹菜单项 -->
<template v-if="contextMenu.target">
<a-menu-item key="open" v-if="contextMenu.target.type === 'file'">打开</a-menu-item>
<a-menu-divider />
<!-- 文件夹操作 -->
<template v-if="dataRef.type === 'directory'">
<a-menu-item key="newFile">新建文件</a-menu-item>
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
<a-menu-divider />
</template>
<a-menu-item key="rename">重命名</a-menu-item>
<a-menu-item key="delete" danger>删除</a-menu-item>
<a-menu-divider />
<a-menu-item key="copyPath">复制路径</a-menu-item>
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
</a-menu>
<template v-if="isMdFile(contextMenu.target.name)">
<a-menu-item key="pin">
{{ isPinned(contextMenu.target.path) ? "取消置顶" : "置顶" }}
</a-menu-item>
</template>
</a-dropdown>
</template>
</a-directory-tree>
</div>
<template #overlay>
<a-menu @click="handleBackgroundMenuClick">
<a-menu-item key="newFile">新建文件</a-menu-item>
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
<a-menu-divider />
<a-menu-item key="refresh">刷新</a-menu-item>
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-menu-divider />
<!-- 文件夹操作 -->
<template v-if="contextMenu.target.type === 'directory'">
<a-menu-item key="newFile">新建文件</a-menu-item>
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
<a-menu-divider />
</template>
<a-menu-item key="rename">重命名</a-menu-item>
<a-menu-item key="delete" danger>删除</a-menu-item>
<a-menu-divider />
<a-menu-item key="copyPath">复制路径</a-menu-item>
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
</template>
<!-- 空白处菜单项 -->
<template v-else>
<a-menu-item key="newFile">新建文件</a-menu-item>
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
<a-menu-divider />
<a-menu-item key="refresh">刷新</a-menu-item>
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
</template>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 重命名/新建弹窗 -->
<a-modal v-model:open="modalVisible" :title="modalTitle" @ok="handleModalOk">
@ -82,7 +86,7 @@
</template>
<script setup>
import { computed, ref, watch, nextTick } from "vue";
import { computed, ref, watch, nextTick, reactive } from "vue";
import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, FileOutlined, FileMarkdownOutlined, FileImageOutlined, FilePdfOutlined, CodeOutlined, FileTextOutlined, FolderFilled, FolderOpenFilled } from "@ant-design/icons-vue";
import { useFileTree } from "../composables/useFileTree";
import { useTabs } from "../composables/useTabs";
@ -90,7 +94,7 @@ import { useConfig } from "../composables/useConfig";
import { message, Modal } from "ant-design-vue";
const { state, loadDirectory, refresh, searchFiles } = useFileTree();
const { openFile, activeTab } = useTabs();
const { openFile, activeTab, closeFile } = useTabs();
const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
const modalVisible = ref(false);
@ -104,6 +108,14 @@ const selectedKeys = ref([]);
const treeContentRef = ref(null);
const modalInputRef = ref(null);
//
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
target: null,
});
watch(modalVisible, (val) => {
if (val) {
nextTick(() => {
@ -317,6 +329,45 @@ const getFileIconColor = (fileName, type) => {
const isPinned = (path) => config.pinnedFiles.includes(path);
//
const onNodeRightClick = ({ event, node }) => {
//
event.stopPropagation();
//
contextMenu.target = node.dataRef;
contextMenu.x = event.clientX;
contextMenu.y = event.clientY;
contextMenu.visible = true;
};
//
const handleBackgroundMenuClick = (event) => {
contextMenu.target = null;
contextMenu.x = event.clientX;
contextMenu.y = event.clientY;
contextMenu.visible = true;
};
//
const handleGlobalMenuClick = ({ key }) => {
contextMenu.visible = false;
let target = contextMenu.target;
if (!target) {
//
const separator = window.utools.isWindows() ? "\\" : "/";
const rootName = state.rootPath.split(separator).pop() || state.rootPath;
target = {
name: rootName,
path: state.rootPath,
type: "directory",
};
}
handleMenuClick({ key }, target);
};
const handleMenuClick = async ({ key }, node) => {
currentNode.value = node;
@ -350,6 +401,12 @@ const handleMenuClick = async ({ key }, node) => {
if (window.services?.deleteItem) {
await window.services.deleteItem(node.path);
message.success("删除成功");
// Tab
if (node.type === 'file') {
closeFile(node.path);
}
await refresh();
}
},
@ -379,29 +436,12 @@ const handleMenuClick = async ({ key }, node) => {
currentAction.value = "newFolder";
modalVisible.value = true;
break;
case "refresh":
handleRefresh();
break;
}
};
const handleBackgroundMenuClick = ({ key }) => {
if (key === "refresh") {
handleRefresh();
return;
}
//
const separator = window.utools.isWindows() ? "\\" : "/";
// C:\pop() state.rootPath
const rootName = state.rootPath.split(separator).pop() || state.rootPath;
const rootNode = {
name: rootName,
path: state.rootPath,
type: "directory",
};
handleMenuClick({ key }, rootNode);
};
const handleModalOk = async () => {
if (!modalInputValue.value) {
message.warning("请输入内容");

View File

@ -5,18 +5,56 @@ const defaultFileTreeConfig = {
};
const config = reactive({
pinnedFiles: [], // Array of string (file paths)
recentFiles: [], // Array of objects { path, lastOpened }
pinnedFiles: [], // Array of string (file paths) - In Memory: Absolute Paths
recentFiles: [], // Array of objects { path, lastOpened } - In Memory: Absolute Paths
fileTree: { ...defaultFileTreeConfig },
});
// Helper: Get system separator
const getSeparator = () => {
if (typeof window !== 'undefined' && window.utools && window.utools.isWindows()) {
return '\\';
}
return '/';
};
// Helper: Convert absolute path to relative path
const getRelativePath = (rootPath, fullPath) => {
if (!rootPath || !fullPath) return fullPath;
if (!fullPath.startsWith(rootPath)) return fullPath;
const sep = getSeparator();
let rel = fullPath.substring(rootPath.length);
if (rel.startsWith(sep)) {
rel = rel.substring(1);
}
return rel;
};
// Helper: Convert relative path to absolute path
const getAbsolutePath = (rootPath, relPath) => {
if (!rootPath || !relPath) return relPath;
// If it looks like an absolute path, return it as is
const sep = getSeparator();
if (sep === '\\' && /^[a-zA-Z]:\\/.test(relPath)) return relPath;
if (sep === '/' && relPath.startsWith('/')) return relPath;
return `${rootPath}${sep}${relPath}`;
};
export function useConfig() {
const loadConfig = async (rootPath) => {
if (!window.services?.loadConfig) return;
const data = await window.services.loadConfig(rootPath);
if (data) {
config.pinnedFiles = data.pinnedFiles || [];
config.recentFiles = data.recentFiles || [];
// Convert stored relative paths to absolute paths for use in app
config.pinnedFiles = (data.pinnedFiles || []).map(p => getAbsolutePath(rootPath, p));
config.recentFiles = (data.recentFiles || []).map(item => ({
...item,
path: getAbsolutePath(rootPath, item.path)
}));
config.fileTree = { ...defaultFileTreeConfig, ...(data.fileTree || {}) };
} else {
config.pinnedFiles = [];
@ -27,9 +65,17 @@ export function useConfig() {
const saveConfig = async (rootPath) => {
if (!window.services?.saveConfig) return;
// Convert absolute paths to relative paths for storage
const pinnedFilesRelative = config.pinnedFiles.map(p => getRelativePath(rootPath, p));
const recentFilesRelative = config.recentFiles.map(item => ({
...item,
path: getRelativePath(rootPath, item.path)
}));
await window.services.saveConfig(rootPath, {
pinnedFiles: config.pinnedFiles,
recentFiles: config.recentFiles,
pinnedFiles: pinnedFilesRelative,
recentFiles: recentFilesRelative,
fileTree: config.fileTree,
});
};

View File

@ -75,6 +75,14 @@ export function useTabs() {
state.tabs.splice(index, 1);
};
// 根据文件路径关闭 Tab
const closeFile = (filePath) => {
const tab = state.tabs.find(t => t.filePath === filePath);
if (tab) {
closeTab(tab.id);
}
};
// 关闭其他 Tab
const closeOtherTabs = (keepTabId) => {
state.tabs = state.tabs.filter(t => t.id === keepTabId || !t.closable);
@ -110,6 +118,7 @@ export function useTabs() {
activeTab,
openFile,
closeTab,
closeFile,
closeOtherTabs,
closeAllTabs,
activateTab,