feat: 新增侧边栏宽度调整、文件树排序与隐藏项显示功能

- 允许用户通过拖拽调整侧边栏宽度,并双击重置为默认宽度
- 文件树目录项现在按类型(目录优先)和名称(自然排序)自动排序
- 新增“显示/隐藏隐藏项”按钮,可过滤以“.”开头及“node_modules”的项
- 为编辑器、预览及整体应用统一设置字体大小变量,提升视觉一致性
- 移除未使用的导入以优化代码结构
This commit is contained in:
cfq 2026-01-26 16:30:01 +08:00
parent 7096b7f6c1
commit fd6aad27f8
8 changed files with 222 additions and 20 deletions

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref, watch } from "vue";
import { onMounted, onUnmounted, ref, watch } from "vue";
import Toolbar from "./components/Toolbar.vue";
import FileTree from "./components/FileTree.vue";
import TabContainer from "./components/TabContainer.vue";
@ -22,6 +22,7 @@ const showGit = ref(false);
const showSettings = ref(false);
const showHistory = ref(false); //
const historyDirs = ref([]); //
const lastOpenedDirCache = ref('');
const themeColors = [
'#1890ff', //
@ -36,6 +37,58 @@ const themeColors = [
const antdTheme = ref(getAntdTheme());
const DEFAULT_SIDEBAR_WIDTH = 250;
const sidebarWidth = ref(DEFAULT_SIDEBAR_WIDTH);
const isResizingSidebar = ref(false);
const sidebarResizeState = ref({ startX: 0, startWidth: DEFAULT_SIDEBAR_WIDTH });
const clampSidebarWidth = (width) => {
const minWidth = 180;
const maxWidth = 720;
return Math.min(maxWidth, Math.max(minWidth, Math.round(width)));
};
const stopSidebarResize = () => {
if (!isResizingSidebar.value) return;
isResizingSidebar.value = false;
window.removeEventListener('pointermove', handleSidebarPointerMove);
window.removeEventListener('pointerup', handleSidebarPointerUp);
window.removeEventListener('pointercancel', handleSidebarPointerUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
const handleSidebarPointerMove = (e) => {
if (!isResizingSidebar.value) return;
const deltaX = e.clientX - sidebarResizeState.value.startX;
sidebarWidth.value = clampSidebarWidth(sidebarResizeState.value.startWidth + deltaX);
};
const handleSidebarPointerUp = () => {
stopSidebarResize();
saveGlobalConfig();
};
const startSidebarResize = (e) => {
if (e.button !== 0) return;
e.preventDefault();
isResizingSidebar.value = true;
sidebarResizeState.value = { startX: e.clientX, startWidth: sidebarWidth.value };
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
window.addEventListener('pointermove', handleSidebarPointerMove);
window.addEventListener('pointerup', handleSidebarPointerUp);
window.addEventListener('pointercancel', handleSidebarPointerUp);
try {
e.currentTarget?.setPointerCapture?.(e.pointerId);
} catch {}
};
const resetSidebarWidth = () => {
sidebarWidth.value = DEFAULT_SIDEBAR_WIDTH;
saveGlobalConfig();
};
//
const loadGlobalConfig = async () => {
if (window.services?.loadGlobalData) {
@ -51,6 +104,14 @@ const loadGlobalConfig = async () => {
historyDirs.value = data.historyDirs;
}
if (data.lastOpenedDir) {
lastOpenedDirCache.value = data.lastOpenedDir;
}
if (data.layout?.sidebarWidth) {
sidebarWidth.value = clampSidebarWidth(data.layout.sidebarWidth);
}
//
if (data.lastOpenedDir && window.services.pathExists(data.lastOpenedDir)) {
await openDirectory(data.lastOpenedDir);
@ -67,8 +128,11 @@ const loadGlobalConfig = async () => {
const saveGlobalConfig = async () => {
if (window.services?.saveGlobalData) {
await window.services.saveGlobalData({
lastOpenedDir: state.rootPath, // from useFileTree
lastOpenedDir: lastOpenedDirCache.value,
historyDirs: historyDirs.value,
layout: {
sidebarWidth: sidebarWidth.value
},
theme: {
primaryColor: primaryColor.value,
isDark: isDark.value
@ -81,6 +145,7 @@ const saveGlobalConfig = async () => {
const openDirectory = async (path) => {
await setRootPath(path);
await loadConfig(path);
lastOpenedDirCache.value = path;
//
if (!historyDirs.value.includes(path)) {
@ -126,6 +191,10 @@ onMounted(() => {
});
});
onUnmounted(() => {
stopSidebarResize();
});
const handleSelectDirectory = async () => {
if (window.services?.selectDirectory) {
const paths = window.services.selectDirectory();
@ -157,10 +226,17 @@ const { state } = useFileTree(); // 需要获取 rootPath 用于保存
/>
<div class="main-content">
<div class="sidebar">
<div class="sidebar" :style="{ width: `${sidebarWidth}px` }">
<FileTree />
</div>
<div
class="sidebar-resizer"
:class="{ resizing: isResizingSidebar }"
@pointerdown="startSidebarResize"
@dblclick="resetSidebarWidth"
/>
<div class="editor-area">
<TabContainer />
</div>
@ -231,13 +307,39 @@ const { state } = useFileTree(); // 需要获取 rootPath 用于保存
}
.sidebar {
width: 250px;
flex: 0 0 auto;
background-color: var(--card-background, #fff);
border-right: 1px solid var(--border-color, #e8e8e8);
display: flex;
flex-direction: column;
}
.sidebar-resizer {
width: 6px;
cursor: col-resize;
position: relative;
flex: 0 0 auto;
background: transparent;
}
.sidebar-resizer::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 2px;
width: 1px;
background: var(--border-color, #e8e8e8);
opacity: 0.8;
}
.sidebar-resizer:hover::before,
.sidebar-resizer.resizing::before {
left: 1px;
width: 2px;
background: var(--primary-color, #1890ff);
opacity: 1;
}
.editor-area {
flex: 1;
display: flex;

View File

@ -2,7 +2,7 @@
<codemirror
v-model="code"
placeholder="请输入 Markdown 内容..."
:style="{ height: '100%', fontSize: '14px' }"
:style="{ height: '100%', fontSize: 'var(--editor-font-size)' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"

View File

@ -2,9 +2,22 @@
<div class="file-tree-container">
<div class="tree-header">
<span class="title">资源管理器</span>
<div class="header-actions">
<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 ? '不显示隐藏项' : '显示隐藏项'"
>
<template #icon>
<EyeOutlined v-if="showHiddenItems" />
<EyeInvisibleOutlined v-else />
</template>
</a-button>
</div>
</div>
<div v-if="!state.rootPath" class="empty-tip">
@ -67,7 +80,7 @@
<script setup>
import { computed, ref } from 'vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { useFileTree } from '../composables/useFileTree';
import { useTabs } from '../composables/useTabs';
import { useConfig } from '../composables/useConfig';
@ -75,7 +88,7 @@ import { message, Modal } from 'ant-design-vue';
const { state, loadDirectory, refresh } = useFileTree();
const { openFile } = useTabs();
const { config, addPinnedFile, removePinnedFile } = useConfig();
const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
const modalVisible = ref(false);
const modalTitle = ref('');
@ -89,11 +102,41 @@ const transformNodes = (nodes) => {
return nodes.map(node => ({
...node,
isLeaf: node.type === 'file',
children: node.children ? transformNodes(node.children) : undefined
children: node.children === undefined ? undefined : transformNodes(node.children)
}));
};
const treeData = computed(() => transformNodes(state.nodes));
const showHiddenItems = computed(() => !!config.fileTree?.showHiddenItems);
const isHiddenItemName = (name) => {
if (!name) return false;
if (name.startsWith('.')) return true;
if (name === 'node_modules') return true;
return false;
};
const filterNodes = (nodes) => {
if (!Array.isArray(nodes) || nodes.length === 0) return [];
if (showHiddenItems.value) {
return nodes.map(node => ({
...node,
children: node.children === undefined ? undefined : filterNodes(node.children)
}));
}
return nodes
.filter(node => !isHiddenItemName(node?.name))
.map(node => ({
...node,
children: node.children === undefined ? undefined : filterNodes(node.children)
}));
};
const treeData = computed(() => transformNodes(filterNodes(state.nodes)));
const toggleShowHiddenItems = async () => {
if (!state.rootPath) return;
await setShowHiddenItems(state.rootPath, !showHiddenItems.value);
};
const handleRefresh = async () => {
await refresh();
@ -102,7 +145,7 @@ const handleRefresh = async () => {
const onLoadData = (treeNode) => {
return new Promise(async (resolve) => {
// 使 key (path)
if (treeNode.dataRef.children && treeNode.dataRef.children.length > 0) {
if (treeNode.dataRef.children !== undefined) {
resolve();
return;
}
@ -253,6 +296,12 @@ const handleModalOk = async () => {
border-bottom: 1px solid var(--border-color, #f0f0f0);
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.title {
font-weight: 600;
}
@ -284,7 +333,8 @@ const handleModalOk = async () => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1; /* allow it to take remaining space but not force width */
flex: 1; /* 让文字占满剩余空间 */
min-width: 0; /* 关键:允许在 flex 容器中收缩,从而触发省略号 */
margin-left: 4px; /* spacing between icon and text */
}
@ -298,6 +348,21 @@ const handleModalOk = async () => {
transition: all 0.2s;
}
/* 关键:把 Title 容器也变成可收缩的 flex 子项,否则长文件名会把布局“挤歪” */
:deep(.ant-tree-title) {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}
:deep(.ant-dropdown-trigger) {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}
:deep(.ant-tree-node-content-wrapper:hover) {
background-color: var(--hover-background, rgba(0, 0, 0, 0.04));
}

View File

@ -90,6 +90,7 @@ const handleClick = (event) => {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
font-size: var(--preview-font-size);
color: var(--text-color);
background-color: var(--card-background);
}
@ -123,7 +124,7 @@ const handleClick = (event) => {
.markdown-preview code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
font-size: var(--preview-code-font-size);
background-color: var(--app-background);
border-radius: 3px;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
@ -132,7 +133,7 @@ const handleClick = (event) => {
.markdown-preview pre {
padding: 16px;
overflow: auto;
font-size: 85%;
font-size: var(--preview-code-font-size);
line-height: 1.45;
background-color: var(--app-background);
border: 1px solid var(--border-color);

View File

@ -1,8 +1,13 @@
import { ref, reactive } from 'vue';
import { reactive } from 'vue';
const defaultFileTreeConfig = {
showHiddenItems: false,
};
const config = reactive({
pinnedFiles: [], // Array of string (file paths)
recentFiles: [], // Array of objects { path, lastOpened }
fileTree: { ...defaultFileTreeConfig },
});
export function useConfig() {
@ -12,9 +17,11 @@ export function useConfig() {
if (data) {
config.pinnedFiles = data.pinnedFiles || [];
config.recentFiles = data.recentFiles || [];
config.fileTree = { ...defaultFileTreeConfig, ...(data.fileTree || {}) };
} else {
config.pinnedFiles = [];
config.recentFiles = [];
config.fileTree = { ...defaultFileTreeConfig };
}
};
@ -23,6 +30,7 @@ export function useConfig() {
await window.services.saveConfig(rootPath, {
pinnedFiles: config.pinnedFiles,
recentFiles: config.recentFiles,
fileTree: config.fileTree,
});
};
@ -56,12 +64,19 @@ export function useConfig() {
await saveConfig(rootPath);
};
const setShowHiddenItems = async (rootPath, showHiddenItems) => {
if (!rootPath) return;
config.fileTree.showHiddenItems = !!showHiddenItems;
await saveConfig(rootPath);
};
return {
config,
loadConfig,
saveConfig,
addPinnedFile,
removePinnedFile,
addRecentFile
addRecentFile,
setShowHiddenItems
};
}

View File

@ -1,4 +1,4 @@
import { reactive, ref } from 'vue';
import { reactive } from 'vue';
// 全局单例状态
const state = reactive({
@ -9,6 +9,15 @@ const state = reactive({
loading: false
});
const compareFileTreeItems = (a, b) => {
const aIsDir = a?.type === 'directory';
const bIsDir = b?.type === 'directory';
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
const aName = a?.name || '';
const bName = b?.name || '';
return aName.localeCompare(bName, undefined, { numeric: true, sensitivity: 'base' });
};
export function useFileTree() {
// 设置根目录并加载
@ -25,6 +34,9 @@ export function useFileTree() {
state.loading = true;
try {
const items = await window.services.readDirectory(path);
if (Array.isArray(items)) {
items.sort(compareFileTreeItems);
}
if (path === state.rootPath) {
// 如果是根目录,直接替换

View File

@ -62,6 +62,7 @@ export function useTheme() {
colorTextSecondary: textColorSecondary,
colorBorder: borderColor,
colorFillTertiary: hoverBackground,
fontSize: 15,
},
};
};

View File

@ -10,6 +10,10 @@
--border-color: #f0f0f0;
--hover-background: rgba(0, 0, 0, 0.04);
--scrollbar-thumb: rgba(0, 0, 0, 0.28);
--app-font-size: 15px;
--editor-font-size: 15px;
--preview-font-size: 15px;
--preview-code-font-size: 15px;
}
html,
@ -21,6 +25,8 @@ body {
body {
background-color: var(--app-background);
color: var(--text-color);
font-size: var(--app-font-size);
line-height: 1.5;
}
button {