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> <script setup>
import { onMounted, ref, watch } from "vue"; import { onMounted, onUnmounted, ref, watch } from "vue";
import Toolbar from "./components/Toolbar.vue"; import Toolbar from "./components/Toolbar.vue";
import FileTree from "./components/FileTree.vue"; import FileTree from "./components/FileTree.vue";
import TabContainer from "./components/TabContainer.vue"; import TabContainer from "./components/TabContainer.vue";
@ -22,6 +22,7 @@ const showGit = ref(false);
const showSettings = ref(false); const showSettings = ref(false);
const showHistory = ref(false); // const showHistory = ref(false); //
const historyDirs = ref([]); // const historyDirs = ref([]); //
const lastOpenedDirCache = ref('');
const themeColors = [ const themeColors = [
'#1890ff', // '#1890ff', //
@ -36,6 +37,58 @@ const themeColors = [
const antdTheme = ref(getAntdTheme()); 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 () => { const loadGlobalConfig = async () => {
if (window.services?.loadGlobalData) { if (window.services?.loadGlobalData) {
@ -51,6 +104,14 @@ const loadGlobalConfig = async () => {
historyDirs.value = data.historyDirs; 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)) { if (data.lastOpenedDir && window.services.pathExists(data.lastOpenedDir)) {
await openDirectory(data.lastOpenedDir); await openDirectory(data.lastOpenedDir);
@ -67,8 +128,11 @@ const loadGlobalConfig = async () => {
const saveGlobalConfig = async () => { const saveGlobalConfig = async () => {
if (window.services?.saveGlobalData) { if (window.services?.saveGlobalData) {
await window.services.saveGlobalData({ await window.services.saveGlobalData({
lastOpenedDir: state.rootPath, // from useFileTree lastOpenedDir: lastOpenedDirCache.value,
historyDirs: historyDirs.value, historyDirs: historyDirs.value,
layout: {
sidebarWidth: sidebarWidth.value
},
theme: { theme: {
primaryColor: primaryColor.value, primaryColor: primaryColor.value,
isDark: isDark.value isDark: isDark.value
@ -81,6 +145,7 @@ const saveGlobalConfig = async () => {
const openDirectory = async (path) => { const openDirectory = async (path) => {
await setRootPath(path); await setRootPath(path);
await loadConfig(path); await loadConfig(path);
lastOpenedDirCache.value = path;
// //
if (!historyDirs.value.includes(path)) { if (!historyDirs.value.includes(path)) {
@ -126,6 +191,10 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
stopSidebarResize();
});
const handleSelectDirectory = async () => { const handleSelectDirectory = async () => {
if (window.services?.selectDirectory) { if (window.services?.selectDirectory) {
const paths = window.services.selectDirectory(); const paths = window.services.selectDirectory();
@ -157,10 +226,17 @@ const { state } = useFileTree(); // 需要获取 rootPath 用于保存
/> />
<div class="main-content"> <div class="main-content">
<div class="sidebar"> <div class="sidebar" :style="{ width: `${sidebarWidth}px` }">
<FileTree /> <FileTree />
</div> </div>
<div
class="sidebar-resizer"
:class="{ resizing: isResizingSidebar }"
@pointerdown="startSidebarResize"
@dblclick="resetSidebarWidth"
/>
<div class="editor-area"> <div class="editor-area">
<TabContainer /> <TabContainer />
</div> </div>
@ -231,13 +307,39 @@ const { state } = useFileTree(); // 需要获取 rootPath 用于保存
} }
.sidebar { .sidebar {
width: 250px; flex: 0 0 auto;
background-color: var(--card-background, #fff); background-color: var(--card-background, #fff);
border-right: 1px solid var(--border-color, #e8e8e8);
display: flex; display: flex;
flex-direction: column; 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 { .editor-area {
flex: 1; flex: 1;
display: flex; display: flex;

View File

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

View File

@ -2,9 +2,22 @@
<div class="file-tree-container"> <div class="file-tree-container">
<div class="tree-header"> <div class="tree-header">
<span class="title">资源管理器</span> <span class="title">资源管理器</span>
<a-button type="text" size="small" @click="handleRefresh" title="刷新"> <div class="header-actions">
<template #icon><ReloadOutlined /></template> <a-button type="text" size="small" @click="handleRefresh" title="刷新">
</a-button> <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>
<div v-if="!state.rootPath" class="empty-tip"> <div v-if="!state.rootPath" class="empty-tip">
@ -67,7 +80,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; 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 { useFileTree } from '../composables/useFileTree';
import { useTabs } from '../composables/useTabs'; import { useTabs } from '../composables/useTabs';
import { useConfig } from '../composables/useConfig'; import { useConfig } from '../composables/useConfig';
@ -75,7 +88,7 @@ import { message, Modal } from 'ant-design-vue';
const { state, loadDirectory, refresh } = useFileTree(); const { state, loadDirectory, refresh } = useFileTree();
const { openFile } = useTabs(); const { openFile } = useTabs();
const { config, addPinnedFile, removePinnedFile } = useConfig(); const { config, addPinnedFile, removePinnedFile, setShowHiddenItems } = useConfig();
const modalVisible = ref(false); const modalVisible = ref(false);
const modalTitle = ref(''); const modalTitle = ref('');
@ -89,11 +102,41 @@ const transformNodes = (nodes) => {
return nodes.map(node => ({ return nodes.map(node => ({
...node, ...node,
isLeaf: node.type === 'file', 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 () => { const handleRefresh = async () => {
await refresh(); await refresh();
@ -102,7 +145,7 @@ const handleRefresh = async () => {
const onLoadData = (treeNode) => { const onLoadData = (treeNode) => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
// 使 key (path) // 使 key (path)
if (treeNode.dataRef.children && treeNode.dataRef.children.length > 0) { if (treeNode.dataRef.children !== undefined) {
resolve(); resolve();
return; return;
} }
@ -253,6 +296,12 @@ const handleModalOk = async () => {
border-bottom: 1px solid var(--border-color, #f0f0f0); border-bottom: 1px solid var(--border-color, #f0f0f0);
} }
.header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.title { .title {
font-weight: 600; font-weight: 600;
} }
@ -284,7 +333,8 @@ const handleModalOk = async () => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 */ margin-left: 4px; /* spacing between icon and text */
} }
@ -298,6 +348,21 @@ const handleModalOk = async () => {
transition: all 0.2s; 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) { :deep(.ant-tree-node-content-wrapper:hover) {
background-color: var(--hover-background, rgba(0, 0, 0, 0.04)); background-color: var(--hover-background, rgba(0, 0, 0, 0.04));
} }

View File

@ -90,6 +90,7 @@ const handleClick = (event) => {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6; line-height: 1.6;
font-size: var(--preview-font-size);
color: var(--text-color); color: var(--text-color);
background-color: var(--card-background); background-color: var(--card-background);
} }
@ -123,7 +124,7 @@ const handleClick = (event) => {
.markdown-preview code { .markdown-preview code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
margin: 0; margin: 0;
font-size: 85%; font-size: var(--preview-code-font-size);
background-color: var(--app-background); background-color: var(--app-background);
border-radius: 3px; border-radius: 3px;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
@ -132,7 +133,7 @@ const handleClick = (event) => {
.markdown-preview pre { .markdown-preview pre {
padding: 16px; padding: 16px;
overflow: auto; overflow: auto;
font-size: 85%; font-size: var(--preview-code-font-size);
line-height: 1.45; line-height: 1.45;
background-color: var(--app-background); background-color: var(--app-background);
border: 1px solid var(--border-color); 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({ const config = reactive({
pinnedFiles: [], // Array of string (file paths) pinnedFiles: [], // Array of string (file paths)
recentFiles: [], // Array of objects { path, lastOpened } recentFiles: [], // Array of objects { path, lastOpened }
fileTree: { ...defaultFileTreeConfig },
}); });
export function useConfig() { export function useConfig() {
@ -12,9 +17,11 @@ export function useConfig() {
if (data) { if (data) {
config.pinnedFiles = data.pinnedFiles || []; config.pinnedFiles = data.pinnedFiles || [];
config.recentFiles = data.recentFiles || []; config.recentFiles = data.recentFiles || [];
config.fileTree = { ...defaultFileTreeConfig, ...(data.fileTree || {}) };
} else { } else {
config.pinnedFiles = []; config.pinnedFiles = [];
config.recentFiles = []; config.recentFiles = [];
config.fileTree = { ...defaultFileTreeConfig };
} }
}; };
@ -23,6 +30,7 @@ export function useConfig() {
await window.services.saveConfig(rootPath, { await window.services.saveConfig(rootPath, {
pinnedFiles: config.pinnedFiles, pinnedFiles: config.pinnedFiles,
recentFiles: config.recentFiles, recentFiles: config.recentFiles,
fileTree: config.fileTree,
}); });
}; };
@ -56,12 +64,19 @@ export function useConfig() {
await saveConfig(rootPath); await saveConfig(rootPath);
}; };
const setShowHiddenItems = async (rootPath, showHiddenItems) => {
if (!rootPath) return;
config.fileTree.showHiddenItems = !!showHiddenItems;
await saveConfig(rootPath);
};
return { return {
config, config,
loadConfig, loadConfig,
saveConfig, saveConfig,
addPinnedFile, addPinnedFile,
removePinnedFile, removePinnedFile,
addRecentFile addRecentFile,
setShowHiddenItems
}; };
} }

View File

@ -1,4 +1,4 @@
import { reactive, ref } from 'vue'; import { reactive } from 'vue';
// 全局单例状态 // 全局单例状态
const state = reactive({ const state = reactive({
@ -9,6 +9,15 @@ const state = reactive({
loading: false 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() { export function useFileTree() {
// 设置根目录并加载 // 设置根目录并加载
@ -25,6 +34,9 @@ export function useFileTree() {
state.loading = true; state.loading = true;
try { try {
const items = await window.services.readDirectory(path); const items = await window.services.readDirectory(path);
if (Array.isArray(items)) {
items.sort(compareFileTreeItems);
}
if (path === state.rootPath) { if (path === state.rootPath) {
// 如果是根目录,直接替换 // 如果是根目录,直接替换

View File

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

View File

@ -10,6 +10,10 @@
--border-color: #f0f0f0; --border-color: #f0f0f0;
--hover-background: rgba(0, 0, 0, 0.04); --hover-background: rgba(0, 0, 0, 0.04);
--scrollbar-thumb: rgba(0, 0, 0, 0.28); --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, html,
@ -21,6 +25,8 @@ body {
body { body {
background-color: var(--app-background); background-color: var(--app-background);
color: var(--text-color); color: var(--text-color);
font-size: var(--app-font-size);
line-height: 1.5;
} }
button { button {