fix(sidebar): 侧边栏四项反馈(docs/17)

空状态按钮对齐页签缩进;启动自动恢复上次工作区(prefs 存 lastWorkspace、
启动校验存在性);空文件夹展开显示「(空)」、文件夹行加粗(展开逻辑经
组件测试证实本就可用);新建泛化到任意文件夹:树头+文档/+文件夹、
文件夹右键在此新建文档/文件夹。vitest 86 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-14 12:27:27 +08:00
parent b6acbf6f30
commit dc78bb0e2a
6 changed files with 212 additions and 15 deletions

33
docs/17-sidebar-fixes.md Normal file
View File

@@ -0,0 +1,33 @@
# 侧边栏四项反馈处理(2026-11-14)
对应大王截图反馈:①「打开文件夹」按钮偏了 ②没进上次打开的文件夹 ③没有下级文件夹 ④不能在文件夹下新建文件夹和文档。验证:tsc 0 错误、vitest **86 通过 + 2 跳过**(新增文件树回归 3 例)、build 通过。
## ① 按钮偏移
空状态区(.sidebar-empty)改为与页签行一致的 10px 左缩进、纵向 flex 左对齐排布,段落默认外边距清零,按钮与上方文字左缘对齐、不再贴边/溢出。
## ② 启动自动恢复上次工作区
原先只把打开过的工作区写进历史表、启动时从不读回。现:打开文件夹时把路径存入偏好(lastWorkspace);启动初始化时读回并校验文件夹仍存在(statFile),存在就自动设为工作区根。不新增 Rust 命令(复用 prefs 通道),Web 模式走 localStorage 同构。
## ③ 下级文件夹
实测结论先说清:**当前代码的文件树本来就支持逐级展开**——专门写了组件级测试(mock 目录数据)验证:文件夹行带 ▸,点击展开能看到下级 .md,逻辑没有缺陷。用户当时看不到下级,最可能两种情况:测试时跑的构建较旧;或子文件夹里没有 .md/.markdown/.txt 文件——文件树按设计只收这三类文档,其余文件不显示,展开后就是空白,容易误读为「没有下级」。
本次做的改进是把反馈做明确,避免再误读:
- 展开后的空文件夹显示一行「(空)」而不是空白;
- 文件夹行加粗区分,▸/▾ 展开箭头保留。
## ④ 在任意文件夹下新建
- 树头两个按钮:「+ 文档」「+ 文件夹」,作用于工作区根目录;
- 文件夹右键菜单新增「在此新建文档…」「在此新建文件夹…」,作用于该文件夹;
- 新建文档沿用原逻辑(补 .md、重名报错不覆盖、建完自动打开);新建文件夹校验名字(不能为空、不能含 Windows 非法字符),建完刷新树并提示。
- 实现:App 的 createFileInWorkspace 泛化为 createFileInDir(dir),新增 createFolderInDir(dir)(走既有 createDirPath 命令)。
## 复验建议(Windows)
- 重启应用:应直接进上次的文件夹,文件树就位。
- 点开任意子文件夹:有文档的显示文档,没有的显示「(空)」。
- 右键一个子文件夹 → 在此新建文档/文件夹:建完树里立刻出现,新文档自动打开。
- 关掉工作区(或新装首次)看空状态:按钮与「尚未打开文件夹。」文字左缘对齐。

View File

@@ -26,7 +26,7 @@ import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile, isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile, pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
@@ -423,6 +423,13 @@ export default function App() {
uiLoadedRef.current = true uiLoadedRef.current = true
const recentFromDb = await store.loadRecent() const recentFromDb = await store.loadRecent()
if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path)) if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path))
// 工作区恢复:自动回到上次打开的文件夹(文件夹已不存在则跳过)
const lastWs = await store.loadLastWorkspace()
if (!cancelled && lastWs) {
try {
if ((await statFile(lastWs)).exists) setWorkspaceRoot(lastWs)
} catch { /* 已删除/不可达时不恢复 */ }
}
// 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理) // 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理)
const records = await store.listRecovery() const records = await store.listRecovery()
if (!cancelled && records.length > 0) { if (!cancelled && records.length > 0) {
@@ -447,7 +454,10 @@ export default function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (workspaceRoot) store.addWorkspace(workspaceRoot) if (workspaceRoot) {
store.addWorkspace(workspaceRoot)
store.saveLastWorkspace(workspaceRoot)
}
}, [workspaceRoot]) }, [workspaceRoot])
// ——— 阶段 4:标签关闭后清理基线与恢复记录 ——— // ——— 阶段 4:标签关闭后清理基线与恢复记录 ———
@@ -576,14 +586,13 @@ export default function App() {
Boolean(a && b && a.replace(/\\/g, '/') === b.replace(/\\/g, '/')) Boolean(a && b && a.replace(/\\/g, '/') === b.replace(/\\/g, '/'))
const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '') const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '')
const createFileInWorkspace = useCallback(() => { const createFileInDir = useCallback((dir: string) => {
if (!workspaceRoot) return
setPromptSpec({ setPromptSpec({
title: '在工作区新建文档', label: '文件名', initial: '未命名.md', title: '新建文档', label: '文件名', initial: '未命名.md',
submit: (name) => { submit: (name) => {
setPromptSpec(null) setPromptSpec(null)
const fileName = /\.(md|markdown|txt)$/i.test(name) ? name : `${name}.md` const fileName = /\.(md|markdown|txt)$/i.test(name) ? name : `${name}.md`
const path = joinPath(workspaceRoot, fileName) const path = joinPath(dir, fileName)
void (async () => { void (async () => {
try { try {
await createTextFile(path) await createTextFile(path)
@@ -593,7 +602,29 @@ export default function App() {
})() })()
}, },
}) })
}, [workspaceRoot, openPath]) }, [openPath ])
const createFolderInDir = useCallback((dir: string) => {
setPromptSpec({
title: '新建文件夹', label: '文件夹名', initial: '新文件夹',
submit: (name) => {
setPromptSpec(null)
const trimmed = name.trim()
if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) {
setError('文件夹名不合法:不能为空,也不能包含 \\ / : * ? " < > |')
return
}
const path = joinPath(dir, trimmed)
void (async () => {
try {
await createDirPath(path)
setTreeRefresh((n) => n + 1)
flash(`已新建文件夹:${trimmed}`)
} catch (e) { setError(String(e)) }
})()
},
})
}, [flash])
const renameEntry = useCallback((entry: DirEntry) => { const renameEntry = useCallback((entry: DirEntry) => {
setPromptSpec({ setPromptSpec({
@@ -1116,7 +1147,8 @@ export default function App() {
activePath={tab?.path ?? null} activePath={tab?.path ?? null}
onOpenFile={handleSidebarOpenFile} onOpenFile={handleSidebarOpenFile}
onOpenFolder={handleSidebarOpenFolder} onOpenFolder={handleSidebarOpenFolder}
onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined} onCreateFileIn={tauriMode && workspaceRoot ? createFileInDir : undefined}
onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined}
onRenameEntry={tauriMode ? renameEntry : undefined} onRenameEntry={tauriMode ? renameEntry : undefined}
onDeleteEntry={tauriMode ? deleteEntry : undefined} onDeleteEntry={tauriMode ? deleteEntry : undefined}
refreshSignal={treeRefresh} refreshSignal={treeRefresh}

View File

@@ -11,7 +11,8 @@ interface Props {
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
onOpenFolder: () => void onOpenFolder: () => void
onCreateFile?: () => void onCreateFileIn?: (dir: string) => void
onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void
refreshSignal?: number refreshSignal?: number
@@ -51,7 +52,8 @@ function Sidebar(p: Props) {
root={p.workspaceRoot} root={p.workspaceRoot}
activePath={p.activePath} activePath={p.activePath}
onOpenFile={p.onOpenFile} onOpenFile={p.onOpenFile}
onCreateFile={p.onCreateFile} onCreateFileIn={p.onCreateFileIn}
onCreateFolderIn={p.onCreateFolderIn}
onRenameEntry={p.onRenameEntry} onRenameEntry={p.onRenameEntry}
onDeleteEntry={p.onDeleteEntry} onDeleteEntry={p.onDeleteEntry}
refreshSignal={p.refreshSignal ?? 0} refreshSignal={p.refreshSignal ?? 0}
@@ -68,11 +70,12 @@ function Sidebar(p: Props) {
) )
} }
function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, onDeleteEntry, refreshSignal }: { function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal }: {
root: string root: string
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
onCreateFile?: () => void onCreateFileIn?: (dir: string) => void
onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void
refreshSignal: number refreshSignal: number
@@ -92,7 +95,8 @@ function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, o
<div className="filetree"> <div className="filetree">
<div className="filetree-head"> <div className="filetree-head">
<span className="filetree-root" title={root}>{baseName(root)}</span> <span className="filetree-root" title={root}>{baseName(root)}</span>
{onCreateFile && <button aria-label="新建文档" title="在工作区新建文档" onClick={onCreateFile}>+</button>} {onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>}
{onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>}
<button aria-label="刷新目录" onClick={() => setRefreshKey((k) => k + 1)}>⟳</button> <button aria-label="刷新目录" onClick={() => setRefreshKey((k) => k + 1)}>⟳</button>
</div> </div>
<TreeLevel <TreeLevel
@@ -102,6 +106,8 @@ function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, o
{menu && ( {menu && (
<div className="tree-context-menu" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}> <div className="tree-context-menu" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>} {!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
{menu.entry.isDir && onCreateFileIn && <button onClick={() => { onCreateFileIn(menu.entry.path); setMenu(null) }}>在此新建文档…</button>}
{menu.entry.isDir && onCreateFolderIn && <button onClick={() => { onCreateFolderIn(menu.entry.path); setMenu(null) }}>在此新建文件夹…</button>}
{onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>} {onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>}
{onDeleteEntry && <button className="danger" onClick={() => { onDeleteEntry(menu.entry); setMenu(null) }}>删除到回收站</button>} {onDeleteEntry && <button className="danger" onClick={() => { onDeleteEntry(menu.entry); setMenu(null) }}>删除到回收站</button>}
</div> </div>
@@ -147,10 +153,11 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
return ( return (
<div> <div>
{entries.length === 0 && <div className="tree-empty" style={{ paddingLeft: 21 + depth * 13 }}>(空)</div>}
{entries.map((entry) => ( {entries.map((entry) => (
<div key={entry.path}> <div key={entry.path}>
<button <button
className={`tree-item${!entry.isDir && entry.path === activePath ? ' active' : ''}`} className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}`}
style={{ paddingLeft: 8 + depth * 13 }} style={{ paddingLeft: 8 + depth * 13 }}
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))} onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }} onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}

View File

@@ -69,6 +69,26 @@ export function addWorkspace(path: string): void {
if (isTauri()) void dbWorkspaceAdd(path, Date.now()).catch(() => undefined) if (isTauri()) void dbWorkspaceAdd(path, Date.now()).catch(() => undefined)
} }
const LS_LAST_WORKSPACE = 'new-markdown:lastWorkspace'
/** 记住最近一次打开的工作区(启动时自动恢复用,走 prefs/localStorage,不新增 Rust 命令)。 */
export function saveLastWorkspace(path: string): void {
if (isTauri()) {
void dbPrefSet('lastWorkspace', path).catch(() => undefined)
return
}
localStorage.setItem(LS_LAST_WORKSPACE, path)
}
export async function loadLastWorkspace(): Promise<string | null> {
if (!isTauri()) return localStorage.getItem(LS_LAST_WORKSPACE)
try {
return await dbPrefGet('lastWorkspace')
} catch {
return null
}
}
export async function loadUiPref(): Promise<string | null> { export async function loadUiPref(): Promise<string | null> {
if (!isTauri()) return null if (!isTauri()) return null
try { try {

View File

@@ -78,12 +78,16 @@ button { font: inherit; color: inherit; }
.outline, .filetree { overflow-y: auto; padding: 6px 8px 16px; } .outline, .filetree { overflow-y: auto; padding: 6px 8px 16px; }
.outline-item { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .outline-item { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.outline-item:hover { background: var(--hover); } .outline-item:hover { background: var(--hover); }
.sidebar-empty { padding: 14px 12px; font-size: 13px; color: var(--text-dim); line-height: 1.6; } .sidebar-empty { padding: 12px 10px; font-size: 13px; color: var(--text-dim); line-height: 1.6; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.sidebar-empty p { margin: 0; }
.sidebar-action, .primary { background: var(--accent); color: #fff; border: 0; border-radius: 7px; padding: 6px 14px; cursor: pointer; } .sidebar-action, .primary { background: var(--accent); color: #fff; border: 0; border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.filetree-head { display: flex; align-items: center; gap: 6px; padding: 2px 4px 6px; } .filetree-head { display: flex; align-items: center; gap: 6px; padding: 2px 4px 6px; }
.filetree-root { font-weight: 600; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .filetree-root { font-weight: 600; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.filetree-head button { border: 0; background: transparent; cursor: pointer; border-radius: 5px; color: var(--text-dim); } .filetree-head button { border: 0; background: transparent; cursor: pointer; border-radius: 5px; color: var(--text-dim); }
.filetree-head button:hover { background: var(--hover); } .filetree-head button:hover { background: var(--hover); }
.filetree-add { font-size: 12px; padding: 2px 6px; white-space: nowrap; }
.tree-item.dir { font-weight: 500; }
.tree-empty { color: var(--text-dim); font-size: 12px; padding-top: 2px; padding-bottom: 2px; }
.tree-item { display: flex; gap: 5px; width: 100%; text-align: left; border: 0; background: transparent; padding: 3.5px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tree-item { display: flex; gap: 5px; width: 100%; text-align: left; border: 0; background: transparent; padding: 3.5px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tree-item:hover { background: var(--hover); } .tree-item:hover { background: var(--hover); }
.tree-item.active { background: var(--accent-weak); } .tree-item.active { background: var(--accent-weak); }

101
tests/sidebar-tree.test.tsx Normal file
View File

@@ -0,0 +1,101 @@
// 文件树回归:子文件夹展开、空文件夹提示、文件夹右键新建文档/文件夹。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
vi.mock('../src/services/platform', async (importOriginal) => {
const orig = await importOriginal<typeof import('../src/services/platform')>()
return {
...orig,
listDir: async (dir: string) => {
if (dir === 'C:/ws') {
return [
{ name: 'sub', path: 'C:/ws/sub', isDir: true },
{ name: 'empty', path: 'C:/ws/empty', isDir: true },
{ name: 'a.md', path: 'C:/ws/a.md', isDir: false },
]
}
if (dir === 'C:/ws/sub') return [{ name: 'b.md', path: 'C:/ws/sub/b.md', isDir: false }]
return []
},
}
})
import Sidebar from '../src/components/Sidebar'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function renderSidebar(extra: Record<string, unknown> = {}) {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(
<Sidebar
mode="files"
onModeChange={() => undefined}
workspaceRoot="C:/ws"
activePath={null}
onOpenFile={() => undefined}
onOpenFolder={() => undefined}
outline={[]}
onJumpHeading={() => undefined}
tauriMode
{...extra}
/>,
)
})
await act(async () => { await sleep(30) })
return { container, root }
}
function findButton(container: HTMLElement, text: string) {
return [...container.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
}
describe('文件树', () => {
it('子文件夹点击展开能看到下级文件', async () => {
const { container, root } = await renderSidebar()
await act(async () => {
findButton(container, 'sub')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => { await sleep(30) })
expect(container.innerHTML).toContain('b.md')
await act(async () => { root.unmount() })
container.remove()
})
it('空文件夹展开后有(空)提示而不是空白', async () => {
const { container, root } = await renderSidebar()
await act(async () => {
findButton(container, 'empty')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => { await sleep(30) })
expect(container.innerHTML).toContain('(空)')
await act(async () => { root.unmount() })
container.remove()
})
it('文件夹右键菜单有新建文档/文件夹并回传该文件夹', async () => {
const created: string[] = []
const folders: string[] = []
const { container, root } = await renderSidebar({
onCreateFileIn: (dir: string) => created.push(dir),
onCreateFolderIn: (dir: string) => folders.push(dir),
})
await act(async () => {
findButton(container, 'sub')?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))
})
await act(async () => { await sleep(10) })
const fileItem = findButton(container, '在此新建文档')
const folderItem = findButton(container, '在此新建文件夹')
expect(fileItem).toBeTruthy()
expect(folderItem).toBeTruthy()
await act(async () => {
fileItem?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(created).toEqual(['C:/ws/sub'])
await act(async () => { root.unmount() })
container.remove()
})
})