feat(文件树和标签页): 添加统一右键菜单和文件路径处理
- 重构文件树右键菜单为全局统一组件,支持节点和空白区域操作 - 添加 closeFile 方法,支持通过文件路径关闭标签页 - 实现配置文件中绝对路径与相对路径的自动转换 - 删除文件时自动关闭对应的标签页
This commit is contained in:
parent
49b89d7ea1
commit
4a6810e57b
|
|
@ -23,29 +23,35 @@
|
||||||
<a-empty description="请选择目录" />
|
<a-empty description="请选择目录" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-dropdown v-else :trigger="['contextmenu']">
|
<div class="tree-content" ref="treeContentRef" @contextmenu.prevent="handleBackgroundMenuClick">
|
||||||
<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" @rightClick="onNodeRightClick">
|
||||||
<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 }">
|
<template #icon="{ dataRef, expanded }">
|
||||||
<component :is="getFileIcon(dataRef.name, dataRef.type, expanded)" :style="{ color: getFileIconColor(dataRef.name, dataRef.type), fontSize: '18px', marginRight: '6px' }" />
|
<component :is="getFileIcon(dataRef.name, dataRef.type, expanded)" :style="{ color: getFileIconColor(dataRef.name, dataRef.type), fontSize: '18px', marginRight: '6px' }" />
|
||||||
</template>
|
</template>
|
||||||
<template #title="{ dataRef }">
|
<template #title="{ dataRef }">
|
||||||
<a-dropdown :trigger="['contextmenu']">
|
|
||||||
<span class="tree-node-title" :title="dataRef.name">{{ dataRef.name }}</span>
|
<span class="tree-node-title" :title="dataRef.name">{{ dataRef.name }}</span>
|
||||||
<template #overlay>
|
</template>
|
||||||
<a-menu @click="(e) => handleMenuClick(e, dataRef)">
|
</a-directory-tree>
|
||||||
<a-menu-item key="open" v-if="dataRef.type === 'file'">打开</a-menu-item>
|
|
||||||
|
|
||||||
<template v-if="isMdFile(dataRef.name)">
|
<!-- 全局右键菜单 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<template v-if="isMdFile(contextMenu.target.name)">
|
||||||
<a-menu-item key="pin">
|
<a-menu-item key="pin">
|
||||||
{{ isPinned(dataRef.path) ? "取消置顶" : "置顶" }}
|
{{ isPinned(contextMenu.target.path) ? "取消置顶" : "置顶" }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-menu-divider />
|
<a-menu-divider />
|
||||||
|
|
||||||
<!-- 文件夹操作 -->
|
<!-- 文件夹操作 -->
|
||||||
<template v-if="dataRef.type === 'directory'">
|
<template v-if="contextMenu.target.type === 'directory'">
|
||||||
<a-menu-item key="newFile">新建文件</a-menu-item>
|
<a-menu-item key="newFile">新建文件</a-menu-item>
|
||||||
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
|
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
|
||||||
<a-menu-divider />
|
<a-menu-divider />
|
||||||
|
|
@ -57,22 +63,20 @@
|
||||||
<a-menu-divider />
|
<a-menu-divider />
|
||||||
<a-menu-item key="copyPath">复制路径</a-menu-item>
|
<a-menu-item key="copyPath">复制路径</a-menu-item>
|
||||||
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
|
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
|
||||||
</a-menu>
|
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
|
||||||
</template>
|
<!-- 空白处菜单项 -->
|
||||||
</a-directory-tree>
|
<template v-else>
|
||||||
</div>
|
|
||||||
<template #overlay>
|
|
||||||
<a-menu @click="handleBackgroundMenuClick">
|
|
||||||
<a-menu-item key="newFile">新建文件</a-menu-item>
|
<a-menu-item key="newFile">新建文件</a-menu-item>
|
||||||
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
|
<a-menu-item key="newFolder">新建文件夹</a-menu-item>
|
||||||
<a-menu-divider />
|
<a-menu-divider />
|
||||||
<a-menu-item key="refresh">刷新</a-menu-item>
|
<a-menu-item key="refresh">刷新</a-menu-item>
|
||||||
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
|
<a-menu-item key="openInExplorer">在资源管理器打开</a-menu-item>
|
||||||
|
</template>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 重命名/新建弹窗 -->
|
<!-- 重命名/新建弹窗 -->
|
||||||
<a-modal v-model:open="modalVisible" :title="modalTitle" @ok="handleModalOk">
|
<a-modal v-model:open="modalVisible" :title="modalTitle" @ok="handleModalOk">
|
||||||
|
|
@ -82,7 +86,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, FileOutlined, FileMarkdownOutlined, FileImageOutlined, FilePdfOutlined, CodeOutlined, FileTextOutlined, FolderFilled, FolderOpenFilled } from "@ant-design/icons-vue";
|
||||||
import { useFileTree } from "../composables/useFileTree";
|
import { useFileTree } from "../composables/useFileTree";
|
||||||
import { useTabs } from "../composables/useTabs";
|
import { useTabs } from "../composables/useTabs";
|
||||||
|
|
@ -90,7 +94,7 @@ import { useConfig } from "../composables/useConfig";
|
||||||
import { message, Modal } from "ant-design-vue";
|
import { message, Modal } from "ant-design-vue";
|
||||||
|
|
||||||
const { state, loadDirectory, refresh, searchFiles } = useFileTree();
|
const { state, loadDirectory, refresh, searchFiles } = useFileTree();
|
||||||
const { openFile, activeTab } = useTabs();
|
const { openFile, activeTab, closeFile } = useTabs();
|
||||||
const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
|
const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
|
||||||
|
|
||||||
const modalVisible = ref(false);
|
const modalVisible = ref(false);
|
||||||
|
|
@ -104,6 +108,14 @@ const selectedKeys = ref([]);
|
||||||
const treeContentRef = ref(null);
|
const treeContentRef = ref(null);
|
||||||
const modalInputRef = ref(null);
|
const modalInputRef = ref(null);
|
||||||
|
|
||||||
|
// 右键菜单状态
|
||||||
|
const contextMenu = reactive({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
target: null,
|
||||||
|
});
|
||||||
|
|
||||||
watch(modalVisible, (val) => {
|
watch(modalVisible, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|
@ -317,6 +329,45 @@ const getFileIconColor = (fileName, type) => {
|
||||||
|
|
||||||
const isPinned = (path) => config.pinnedFiles.includes(path);
|
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) => {
|
const handleMenuClick = async ({ key }, node) => {
|
||||||
currentNode.value = node;
|
currentNode.value = node;
|
||||||
|
|
||||||
|
|
@ -350,6 +401,12 @@ const handleMenuClick = async ({ key }, node) => {
|
||||||
if (window.services?.deleteItem) {
|
if (window.services?.deleteItem) {
|
||||||
await window.services.deleteItem(node.path);
|
await window.services.deleteItem(node.path);
|
||||||
message.success("删除成功");
|
message.success("删除成功");
|
||||||
|
|
||||||
|
// 关闭对应的 Tab
|
||||||
|
if (node.type === 'file') {
|
||||||
|
closeFile(node.path);
|
||||||
|
}
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -379,27 +436,10 @@ const handleMenuClick = async ({ key }, node) => {
|
||||||
currentAction.value = "newFolder";
|
currentAction.value = "newFolder";
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
break;
|
break;
|
||||||
}
|
case "refresh":
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackgroundMenuClick = ({ key }) => {
|
|
||||||
if (key === "refresh") {
|
|
||||||
handleRefresh();
|
handleRefresh();
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建根目录的虚拟节点
|
|
||||||
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 () => {
|
const handleModalOk = async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,56 @@ const defaultFileTreeConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = reactive({
|
const config = reactive({
|
||||||
pinnedFiles: [], // Array of string (file paths)
|
pinnedFiles: [], // Array of string (file paths) - In Memory: Absolute Paths
|
||||||
recentFiles: [], // Array of objects { path, lastOpened }
|
recentFiles: [], // Array of objects { path, lastOpened } - In Memory: Absolute Paths
|
||||||
fileTree: { ...defaultFileTreeConfig },
|
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() {
|
export function useConfig() {
|
||||||
const loadConfig = async (rootPath) => {
|
const loadConfig = async (rootPath) => {
|
||||||
if (!window.services?.loadConfig) return;
|
if (!window.services?.loadConfig) return;
|
||||||
const data = await window.services.loadConfig(rootPath);
|
const data = await window.services.loadConfig(rootPath);
|
||||||
if (data) {
|
if (data) {
|
||||||
config.pinnedFiles = data.pinnedFiles || [];
|
// Convert stored relative paths to absolute paths for use in app
|
||||||
config.recentFiles = data.recentFiles || [];
|
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 || {}) };
|
config.fileTree = { ...defaultFileTreeConfig, ...(data.fileTree || {}) };
|
||||||
} else {
|
} else {
|
||||||
config.pinnedFiles = [];
|
config.pinnedFiles = [];
|
||||||
|
|
@ -27,9 +65,17 @@ export function useConfig() {
|
||||||
|
|
||||||
const saveConfig = async (rootPath) => {
|
const saveConfig = async (rootPath) => {
|
||||||
if (!window.services?.saveConfig) return;
|
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, {
|
await window.services.saveConfig(rootPath, {
|
||||||
pinnedFiles: config.pinnedFiles,
|
pinnedFiles: pinnedFilesRelative,
|
||||||
recentFiles: config.recentFiles,
|
recentFiles: recentFilesRelative,
|
||||||
fileTree: config.fileTree,
|
fileTree: config.fileTree,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ export function useTabs() {
|
||||||
state.tabs.splice(index, 1);
|
state.tabs.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据文件路径关闭 Tab
|
||||||
|
const closeFile = (filePath) => {
|
||||||
|
const tab = state.tabs.find(t => t.filePath === filePath);
|
||||||
|
if (tab) {
|
||||||
|
closeTab(tab.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 关闭其他 Tab
|
// 关闭其他 Tab
|
||||||
const closeOtherTabs = (keepTabId) => {
|
const closeOtherTabs = (keepTabId) => {
|
||||||
state.tabs = state.tabs.filter(t => t.id === keepTabId || !t.closable);
|
state.tabs = state.tabs.filter(t => t.id === keepTabId || !t.closable);
|
||||||
|
|
@ -110,6 +118,7 @@ export function useTabs() {
|
||||||
activeTab,
|
activeTab,
|
||||||
openFile,
|
openFile,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
closeFile,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
activateTab,
|
activateTab,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue