feat(文件树和标签页): 添加统一右键菜单和文件路径处理
- 重构文件树右键菜单为全局统一组件,支持节点和空白区域操作 - 添加 closeFile 方法,支持通过文件路径关闭标签页 - 实现配置文件中绝对路径与相对路径的自动转换 - 删除文件时自动关闭对应的标签页
This commit is contained in:
parent
49b89d7ea1
commit
4a6810e57b
|
|
@ -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("请输入内容");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue