fix(sidebar): 侧边栏四项反馈(docs/17)
空状态按钮对齐页签缩进;启动自动恢复上次工作区(prefs 存 lastWorkspace、 启动校验存在性);空文件夹展开显示「(空)」、文件夹行加粗(展开逻辑经 组件测试证实本就可用);新建泛化到任意文件夹:树头+文档/+文件夹、 文件夹右键在此新建文档/文件夹。vitest 86 通过 + 2 跳过。
This commit is contained in:
33
docs/17-sidebar-fixes.md
Normal file
33
docs/17-sidebar-fixes.md
Normal 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)
|
||||||
|
|
||||||
|
- 重启应用:应直接进上次的文件夹,文件树就位。
|
||||||
|
- 点开任意子文件夹:有文档的显示文档,没有的显示「(空)」。
|
||||||
|
- 右键一个子文件夹 → 在此新建文档/文件夹:建完树里立刻出现,新文档自动打开。
|
||||||
|
- 关掉工作区(或新装首次)看空状态:按钮与「尚未打开文件夹。」文字左缘对齐。
|
||||||
48
src/App.tsx
48
src/App.tsx
@@ -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}
|
||||||
|
|||||||
@@ -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) }}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
101
tests/sidebar-tree.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user