fix(sidebar): 侧边栏四项反馈(docs/17)
空状态按钮对齐页签缩进;启动自动恢复上次工作区(prefs 存 lastWorkspace、 启动校验存在性);空文件夹展开显示「(空)」、文件夹行加粗(展开逻辑经 组件测试证实本就可用);新建泛化到任意文件夹:树头+文档/+文件夹、 文件夹右键在此新建文档/文件夹。vitest 86 通过 + 2 跳过。
This commit is contained in:
48
src/App.tsx
48
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user