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

View File

@@ -26,7 +26,7 @@ import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile,
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,
} from './services/platform'
import * as store from './services/store'
@@ -423,6 +423,13 @@ export default function App() {
uiLoadedRef.current = true
const recentFromDb = await store.loadRecent()
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()
if (!cancelled && records.length > 0) {
@@ -447,7 +454,10 @@ export default function App() {
}, [])
useEffect(() => {
if (workspaceRoot) store.addWorkspace(workspaceRoot)
if (workspaceRoot) {
store.addWorkspace(workspaceRoot)
store.saveLastWorkspace(workspaceRoot)
}
}, [workspaceRoot])
// ——— 阶段 4:标签关闭后清理基线与恢复记录 ———
@@ -576,14 +586,13 @@ export default function App() {
Boolean(a && b && a.replace(/\\/g, '/') === b.replace(/\\/g, '/'))
const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '')
const createFileInWorkspace = useCallback(() => {
if (!workspaceRoot) return
const createFileInDir = useCallback((dir: string) => {
setPromptSpec({
title: '在工作区新建文档', label: '文件名', initial: '未命名.md',
title: '新建文档', label: '文件名', initial: '未命名.md',
submit: (name) => {
setPromptSpec(null)
const fileName = /\.(md|markdown|txt)$/i.test(name) ? name : `${name}.md`
const path = joinPath(workspaceRoot, fileName)
const path = joinPath(dir, fileName)
void (async () => {
try {
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) => {
setPromptSpec({
@@ -1116,7 +1147,8 @@ export default function App() {
activePath={tab?.path ?? null}
onOpenFile={handleSidebarOpenFile}
onOpenFolder={handleSidebarOpenFolder}
onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined}
onCreateFileIn={tauriMode && workspaceRoot ? createFileInDir : undefined}
onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined}
onRenameEntry={tauriMode ? renameEntry : undefined}
onDeleteEntry={tauriMode ? deleteEntry : undefined}
refreshSignal={treeRefresh}

View File

@@ -11,7 +11,8 @@ interface Props {
activePath: string | null
onOpenFile: (path: string) => void
onOpenFolder: () => void
onCreateFile?: () => void
onCreateFileIn?: (dir: string) => void
onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void
refreshSignal?: number
@@ -51,7 +52,8 @@ function Sidebar(p: Props) {
root={p.workspaceRoot}
activePath={p.activePath}
onOpenFile={p.onOpenFile}
onCreateFile={p.onCreateFile}
onCreateFileIn={p.onCreateFileIn}
onCreateFolderIn={p.onCreateFolderIn}
onRenameEntry={p.onRenameEntry}
onDeleteEntry={p.onDeleteEntry}
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
activePath: string | null
onOpenFile: (path: string) => void
onCreateFile?: () => void
onCreateFileIn?: (dir: string) => void
onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void
refreshSignal: number
@@ -92,7 +95,8 @@ function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, o
<div className="filetree">
<div className="filetree-head">
<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>
</div>
<TreeLevel
@@ -102,6 +106,8 @@ function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, o
{menu && (
<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 && 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>}
{onDeleteEntry && <button className="danger" onClick={() => { onDeleteEntry(menu.entry); setMenu(null) }}>删除到回收站</button>}
</div>
@@ -147,10 +153,11 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
return (
<div>
{entries.length === 0 && <div className="tree-empty" style={{ paddingLeft: 21 + depth * 13 }}>(空)</div>}
{entries.map((entry) => (
<div key={entry.path}>
<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 }}
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
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)
}
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> {
if (!isTauri()) return null
try {

View File

@@ -78,12 +78,16 @@ button { font: inherit; color: inherit; }
.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: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; }
.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-head button { border: 0; background: transparent; cursor: pointer; border-radius: 5px; color: var(--text-dim); }
.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:hover { background: var(--hover); }
.tree-item.active { background: var(--accent-weak); }