feat(文件树): 实现文件树与标签页的联动选择和滚动定位

- 在 HomeTab 中显示文件名和完整路径,提升可读性
- 监听 activeTab 变化,自动在文件树中选中对应文件并展开父目录
- 添加滚动定位功能,确保选中节点在可视区域内
- 优化文件树模板结构,提高代码可维护性
This commit is contained in:
cfq 2026-01-28 14:43:20 +08:00
parent e29391c893
commit e56fc4b5f1
2 changed files with 201 additions and 162 deletions

View File

@ -1,13 +1,7 @@
<template> <template>
<div class="file-tree-container"> <div class="file-tree-container">
<div class="tree-header"> <div class="tree-header">
<a-input <a-input v-model:value="searchKeyword" placeholder="搜索文件..." size="small" allow-clear class="search-input">
v-model:value="searchKeyword"
placeholder="搜索文件..."
size="small"
allow-clear
class="search-input"
>
<template #prefix> <template #prefix>
<SearchOutlined /> <SearchOutlined />
</template> </template>
@ -16,12 +10,7 @@
<a-button type="text" size="small" @click="handleRefresh" title="刷新"> <a-button type="text" size="small" @click="handleRefresh" title="刷新">
<template #icon><ReloadOutlined /></template> <template #icon><ReloadOutlined /></template>
</a-button> </a-button>
<a-button <a-button type="text" size="small" @click="toggleShowHiddenItems" :title="showHiddenItems ? '不显示隐藏项' : '显示隐藏项'">
type="text"
size="small"
@click="toggleShowHiddenItems"
:title="showHiddenItems ? '不显示隐藏项' : '显示隐藏项'"
>
<template #icon> <template #icon>
<EyeOutlined v-if="showHiddenItems" /> <EyeOutlined v-if="showHiddenItems" />
<EyeInvisibleOutlined v-else /> <EyeInvisibleOutlined v-else />
@ -34,19 +23,10 @@
<a-empty description="请选择目录" /> <a-empty description="请选择目录" />
</div> </div>
<div v-else class="tree-content"> <div v-else class="tree-content" ref="treeContentRef">
<a-directory-tree <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">
v-model:expandedKeys="state.expandedKeys"
: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 <component :is="getFileIcon(dataRef.name, dataRef.type, expanded)" :style="{ color: getFileIconColor(dataRef.name, dataRef.type), fontSize: '18px', marginRight: '6px' }" />
: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']"> <a-dropdown :trigger="['contextmenu']">
@ -57,7 +37,7 @@
<template v-if="isMdFile(dataRef.name)"> <template v-if="isMdFile(dataRef.name)">
<a-menu-item key="pin"> <a-menu-item key="pin">
{{ isPinned(dataRef.path) ? '取消置顶' : '置顶' }} {{ isPinned(dataRef.path) ? "取消置顶" : "置顶" }}
</a-menu-item> </a-menu-item>
</template> </template>
@ -84,55 +64,115 @@
</div> </div>
<!-- 重命名/新建弹窗 --> <!-- 重命名/新建弹窗 -->
<a-modal <a-modal v-model:open="modalVisible" :title="modalTitle" @ok="handleModalOk">
v-model:open="modalVisible"
:title="modalTitle"
@ok="handleModalOk"
>
<a-input v-model:value="modalInputValue" :placeholder="modalPlaceholder" /> <a-input v-model:value="modalInputValue" :placeholder="modalPlaceholder" />
</a-modal> </a-modal>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch, nextTick } from "vue";
import { import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, FileOutlined, FileMarkdownOutlined, FileImageOutlined, FilePdfOutlined, CodeOutlined, FileTextOutlined, FolderFilled, FolderOpenFilled } from "@ant-design/icons-vue";
EyeInvisibleOutlined, import { useFileTree } from "../composables/useFileTree";
EyeOutlined, import { useTabs } from "../composables/useTabs";
ReloadOutlined, import { useConfig } from "../composables/useConfig";
SearchOutlined, import { message, Modal } from "ant-design-vue";
FileOutlined,
FileMarkdownOutlined,
FileImageOutlined,
FilePdfOutlined,
CodeOutlined,
FileTextOutlined,
FolderFilled,
FolderOpenFilled
} from '@ant-design/icons-vue';
import { useFileTree } from '../composables/useFileTree';
import { useTabs } from '../composables/useTabs';
import { useConfig } from '../composables/useConfig';
import { message, Modal } from 'ant-design-vue';
const { state, loadDirectory, refresh, searchFiles } = useFileTree(); const { state, loadDirectory, refresh, searchFiles } = useFileTree();
const { openFile } = useTabs(); const { openFile, activeTab } = useTabs();
const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig(); const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
const modalVisible = ref(false); const modalVisible = ref(false);
const modalTitle = ref(''); const modalTitle = ref("");
const modalInputValue = ref(''); const modalInputValue = ref("");
const modalPlaceholder = ref(''); const modalPlaceholder = ref("");
const currentAction = ref(null); const currentAction = ref(null);
const currentNode = ref(null); const currentNode = ref(null);
const searchKeyword = ref(''); const searchKeyword = ref("");
const selectedKeys = ref([]);
const treeContentRef = ref(null);
// activeTab
watch(
activeTab,
async (tab) => {
if (tab?.filePath) {
await revealFile(tab.filePath);
}
},
{ immediate: true },
);
const revealFile = async (filePath) => {
if (!state.rootPath || !filePath.startsWith(state.rootPath)) return;
// 1.
selectedKeys.value = [filePath];
const separator = window.utools.isWindows() ? "\\" : "/";
// 2.
let currentPath = filePath;
const parentsToLoad = [];
while (true) {
const lastSepIndex = currentPath.lastIndexOf(separator);
if (lastSepIndex === -1) break;
currentPath = currentPath.substring(0, lastSepIndex);
//
// state.rootPath
if (currentPath.length < state.rootPath.length) break;
if (currentPath === state.rootPath) break;
parentsToLoad.unshift(currentPath);
}
// 3.
for (const path of parentsToLoad) {
// 使
//
if (!state.expandedKeys.includes(path)) {
//
await loadDirectory(path);
state.expandedKeys.push(path);
}
}
// 4.
// Ant Design Vue Tree virtualscrollTo
// DOM 使 scrollIntoView
// DOM
const tryScroll = (retryCount = 0) => {
if (!treeContentRef.value) return;
//
// ant-tree-treenode-selected () ant-tree-node-selected ()
const selectedNode = treeContentRef.value.querySelector(".ant-tree-treenode-selected") || treeContentRef.value.querySelector(".ant-tree-node-selected");
if (selectedNode) {
selectedNode.scrollIntoView({ block: "center", behavior: "smooth" });
} else {
//
// 100ms * 10 = 1s DOM
if (retryCount < 20) {
setTimeout(() => tryScroll(retryCount + 1), 100);
}
}
};
nextTick(() => {
tryScroll();
});
};
// nodes treeData isLeaf // nodes treeData isLeaf
const transformNodes = (nodes) => { const transformNodes = (nodes) => {
return nodes.map(node => ({ return nodes.map((node) => ({
...node, ...node,
isLeaf: node.type === 'file', isLeaf: node.type === "file",
children: node.children === undefined ? undefined : transformNodes(node.children) children: node.children === undefined ? undefined : transformNodes(node.children),
})); }));
}; };
@ -140,8 +180,8 @@ const showHiddenItems = computed(() => !!config.fileTree?.showHiddenItems);
const isHiddenItemName = (name) => { const isHiddenItemName = (name) => {
if (!name) return false; if (!name) return false;
if (name.startsWith('.')) return true; if (name.startsWith(".")) return true;
if (name === 'node_modules') return true; if (name === "node_modules") return true;
return false; return false;
}; };
@ -151,13 +191,13 @@ const filterNodes = (nodes) => {
// 1. // 1.
let filtered = nodes; let filtered = nodes;
if (!showHiddenItems.value) { if (!showHiddenItems.value) {
filtered = nodes.filter(node => !isHiddenItemName(node?.name)); filtered = nodes.filter((node) => !isHiddenItemName(node?.name));
} }
// //
return filtered.map(node => ({ return filtered.map((node) => ({
...node, ...node,
children: node.children === undefined ? undefined : filterNodes(node.children) children: node.children === undefined ? undefined : filterNodes(node.children),
})); }));
}; };
@ -201,46 +241,46 @@ const onLoadData = (treeNode) => {
}; };
const onSelect = (keys, { node }) => { const onSelect = (keys, { node }) => {
if (node.dataRef.type === 'file') { if (node.dataRef.type === "file") {
openFile(node.dataRef, state.rootPath); openFile(node.dataRef, state.rootPath);
} }
}; };
const isMdFile = (name) => name && name.toLowerCase().endsWith('.md'); const isMdFile = (name) => name && name.toLowerCase().endsWith(".md");
const FILE_TYPE_CONFIG = { const FILE_TYPE_CONFIG = {
md: { icon: FileMarkdownOutlined, color: '#1890ff' }, md: { icon: FileMarkdownOutlined, color: "#1890ff" },
markdown: { icon: FileMarkdownOutlined, color: '#1890ff' }, markdown: { icon: FileMarkdownOutlined, color: "#1890ff" },
png: { icon: FileImageOutlined, color: '#722ed1' }, png: { icon: FileImageOutlined, color: "#722ed1" },
jpg: { icon: FileImageOutlined, color: '#722ed1' }, jpg: { icon: FileImageOutlined, color: "#722ed1" },
jpeg: { icon: FileImageOutlined, color: '#722ed1' }, jpeg: { icon: FileImageOutlined, color: "#722ed1" },
gif: { icon: FileImageOutlined, color: '#722ed1' }, gif: { icon: FileImageOutlined, color: "#722ed1" },
webp: { icon: FileImageOutlined, color: '#722ed1' }, webp: { icon: FileImageOutlined, color: "#722ed1" },
svg: { icon: FileImageOutlined, color: '#722ed1' }, svg: { icon: FileImageOutlined, color: "#722ed1" },
ico: { icon: FileImageOutlined, color: '#722ed1' }, ico: { icon: FileImageOutlined, color: "#722ed1" },
pdf: { icon: FilePdfOutlined, color: '#f5222d' }, pdf: { icon: FilePdfOutlined, color: "#f5222d" },
js: { icon: CodeOutlined, color: '#52c41a' }, js: { icon: CodeOutlined, color: "#52c41a" },
json: { icon: CodeOutlined, color: '#52c41a' }, json: { icon: CodeOutlined, color: "#52c41a" },
ts: { icon: CodeOutlined, color: '#52c41a' }, ts: { icon: CodeOutlined, color: "#52c41a" },
css: { icon: CodeOutlined, color: '#52c41a' }, css: { icon: CodeOutlined, color: "#52c41a" },
html: { icon: CodeOutlined, color: '#52c41a' }, html: { icon: CodeOutlined, color: "#52c41a" },
vue: { icon: CodeOutlined, color: '#52c41a' }, vue: { icon: CodeOutlined, color: "#52c41a" },
xml: { icon: CodeOutlined, color: '#52c41a' }, xml: { icon: CodeOutlined, color: "#52c41a" },
yaml: { icon: CodeOutlined, color: '#52c41a' }, yaml: { icon: CodeOutlined, color: "#52c41a" },
yml: { icon: CodeOutlined, color: '#52c41a' }, yml: { icon: CodeOutlined, color: "#52c41a" },
txt: { icon: FileTextOutlined, color: 'inherit' }, txt: { icon: FileTextOutlined, color: "inherit" },
log: { icon: FileTextOutlined, color: 'inherit' }, log: { icon: FileTextOutlined, color: "inherit" },
}; };
const getFileExtension = (fileName) => { const getFileExtension = (fileName) => {
if (!fileName) return ''; if (!fileName) return "";
const lastDotIndex = fileName.lastIndexOf('.'); const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) return ''; if (lastDotIndex === -1 || lastDotIndex === 0) return "";
return fileName.substring(lastDotIndex + 1).toLowerCase(); return fileName.substring(lastDotIndex + 1).toLowerCase();
}; };
const getFileIcon = (fileName, type, expanded) => { const getFileIcon = (fileName, type, expanded) => {
if (type === 'directory') { if (type === "directory") {
return expanded ? FolderOpenFilled : FolderFilled; return expanded ? FolderOpenFilled : FolderFilled;
} }
const ext = getFileExtension(fileName); const ext = getFileExtension(fileName);
@ -248,11 +288,11 @@ const getFileIcon = (fileName, type, expanded) => {
}; };
const getFileIconColor = (fileName, type) => { const getFileIconColor = (fileName, type) => {
if (type === 'directory') { if (type === "directory") {
return '#ffc60a'; // return "#ffc60a"; //
} }
const ext = getFileExtension(fileName); const ext = getFileExtension(fileName);
return FILE_TYPE_CONFIG[ext]?.color || 'inherit'; return FILE_TYPE_CONFIG[ext]?.color || "inherit";
}; };
const isPinned = (path) => config.pinnedFiles.includes(path); const isPinned = (path) => config.pinnedFiles.includes(path);
@ -261,62 +301,62 @@ const handleMenuClick = async ({ key }, node) => {
currentNode.value = node; currentNode.value = node;
switch (key) { switch (key) {
case 'open': case "open":
openFile(node, state.rootPath); openFile(node, state.rootPath);
break; break;
case 'pin': case "pin":
if (isPinned(node.path)) { if (isPinned(node.path)) {
await removePinnedFile(state.rootPath, node.path); await removePinnedFile(state.rootPath, node.path);
message.success('已取消置顶'); message.success("已取消置顶");
} else { } else {
await addPinnedFile(state.rootPath, node.path); await addPinnedFile(state.rootPath, node.path);
message.success('已置顶'); message.success("已置顶");
} }
break; break;
case 'rename': case "rename":
modalTitle.value = '重命名'; modalTitle.value = "重命名";
modalInputValue.value = node.name; modalInputValue.value = node.name;
currentAction.value = 'rename'; currentAction.value = "rename";
modalVisible.value = true; modalVisible.value = true;
break; break;
case 'delete': case "delete":
Modal.confirm({ Modal.confirm({
title: '确认删除', title: "确认删除",
content: `确定要删除 ${node.name} 吗?此操作不可恢复。`, content: `确定要删除 ${node.name} 吗?此操作不可恢复。`,
okText: '删除', okText: "删除",
okType: 'danger', okType: "danger",
cancelText: '取消', cancelText: "取消",
onOk: async () => { onOk: async () => {
if (window.services?.deleteItem) { if (window.services?.deleteItem) {
await window.services.deleteItem(node.path); await window.services.deleteItem(node.path);
message.success('删除成功'); message.success("删除成功");
await refresh(); await refresh();
} }
} },
}); });
break; break;
case 'copyPath': case "copyPath":
// 使 uTools // 使 uTools
window.utools.copyText(node.path); window.utools.copyText(node.path);
message.success('路径已复制'); message.success("路径已复制");
break; break;
case 'openInExplorer': case "openInExplorer":
if (window.services?.openItemLocation) { if (window.services?.openItemLocation) {
window.services.openItemLocation(node.path); window.services.openItemLocation(node.path);
} }
break; break;
case 'newFile': case "newFile":
modalTitle.value = '新建文件'; modalTitle.value = "新建文件";
modalInputValue.value = ''; modalInputValue.value = "";
modalPlaceholder.value = '请输入文件名 (包含后缀)'; modalPlaceholder.value = "请输入文件名 (包含后缀)";
currentAction.value = 'newFile'; currentAction.value = "newFile";
modalVisible.value = true; modalVisible.value = true;
break; break;
case 'newFolder': case "newFolder":
modalTitle.value = '新建文件夹'; modalTitle.value = "新建文件夹";
modalInputValue.value = ''; modalInputValue.value = "";
modalPlaceholder.value = '请输入文件夹名'; modalPlaceholder.value = "请输入文件夹名";
currentAction.value = 'newFolder'; currentAction.value = "newFolder";
modalVisible.value = true; modalVisible.value = true;
break; break;
} }
@ -324,7 +364,7 @@ const handleMenuClick = async ({ key }, node) => {
const handleModalOk = async () => { const handleModalOk = async () => {
if (!modalInputValue.value) { if (!modalInputValue.value) {
message.warning('请输入内容'); message.warning("请输入内容");
return; return;
} }
@ -335,39 +375,39 @@ const handleModalOk = async () => {
// directory // directory
// file // file
let parentPath = ''; let parentPath = "";
if (currentAction.value === 'newFile' || currentAction.value === 'newFolder') { if (currentAction.value === "newFile" || currentAction.value === "newFolder") {
if (node.type === 'directory') { if (node.type === "directory") {
parentPath = node.path; parentPath = node.path;
} else { } else {
parentPath = node.path.substring(0, node.path.lastIndexOf(window.utools.isWindows() ? '\\' : '/')); parentPath = node.path.substring(0, node.path.lastIndexOf(window.utools.isWindows() ? "\\" : "/"));
} }
} else { } else {
// rename // rename
parentPath = node.path.substring(0, node.path.lastIndexOf(window.utools.isWindows() ? '\\' : '/')); parentPath = node.path.substring(0, node.path.lastIndexOf(window.utools.isWindows() ? "\\" : "/"));
} }
try { try {
if (currentAction.value === 'rename') { if (currentAction.value === "rename") {
const newPath = node.path.replace(node.name, modalInputValue.value); const newPath = node.path.replace(node.name, modalInputValue.value);
await window.services.renameItem(node.path, newPath); await window.services.renameItem(node.path, newPath);
message.success('重命名成功'); message.success("重命名成功");
} else if (currentAction.value === 'newFile') { } else if (currentAction.value === "newFile") {
const separator = window.utools.isWindows() ? '\\' : '/'; const separator = window.utools.isWindows() ? "\\" : "/";
const newPath = `${parentPath}${separator}${modalInputValue.value}`; const newPath = `${parentPath}${separator}${modalInputValue.value}`;
await window.services.writeFile(newPath, ''); // await window.services.writeFile(newPath, ""); //
message.success('创建文件成功'); message.success("创建文件成功");
} else if (currentAction.value === 'newFolder') { } else if (currentAction.value === "newFolder") {
const separator = window.utools.isWindows() ? '\\' : '/'; const separator = window.utools.isWindows() ? "\\" : "/";
const newPath = `${parentPath}${separator}${modalInputValue.value}`; const newPath = `${parentPath}${separator}${modalInputValue.value}`;
await window.services.createDirectory(newPath); await window.services.createDirectory(newPath);
message.success('创建文件夹成功'); message.success("创建文件夹成功");
} }
modalVisible.value = false; modalVisible.value = false;
await refresh(); await refresh();
} catch (error) { } catch (error) {
message.error('操作失败: ' + error.message); message.error("操作失败: " + error.message);
} }
}; };
</script> </script>
@ -417,7 +457,6 @@ const handleModalOk = async () => {
padding-bottom: 4px; padding-bottom: 4px;
} }
.empty-tip { .empty-tip {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@ -468,5 +507,4 @@ const handleModalOk = async () => {
background-color: var(--primary-color-bg, #e6f7ff) !important; background-color: var(--primary-color-bg, #e6f7ff) !important;
color: var(--primary-color, #1890ff); color: var(--primary-color, #1890ff);
} }
</style> </style>

View File

@ -15,7 +15,8 @@
<a-list-item class="file-item" @click="handleOpenFile(item)"> <a-list-item class="file-item" @click="handleOpenFile(item)">
<div class="file-info"> <div class="file-info">
<FileMarkdownOutlined /> <FileMarkdownOutlined />
<span class="file-path">{{ item }}</span> <span class="file-path">{{ getFileName(item) }}</span>
<span class="file-path-sub">{{ item }}</span>
</div> </div>
</a-list-item> </a-list-item>
</template> </template>