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

View File

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