diff --git a/docs/17-sidebar-fixes.md b/docs/17-sidebar-fixes.md
new file mode 100644
index 0000000..e92b9eb
--- /dev/null
+++ b/docs/17-sidebar-fixes.md
@@ -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)
+
+- 重启应用:应直接进上次的文件夹,文件树就位。
+- 点开任意子文件夹:有文档的显示文档,没有的显示「(空)」。
+- 右键一个子文件夹 → 在此新建文档/文件夹:建完树里立刻出现,新文档自动打开。
+- 关掉工作区(或新装首次)看空状态:按钮与「尚未打开文件夹。」文字左缘对齐。
diff --git a/src/App.tsx b/src/App.tsx
index 729f787..ced895d 100644
--- a/src/App.tsx
+++ b/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}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 898c3f5..2b3c57c 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -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
{baseName(root)}
- {onCreateFile && }
+ {onCreateFileIn && }
+ {onCreateFolderIn && }
e.stopPropagation()}>
{!menu.entry.isDir && }
+ {menu.entry.isDir && onCreateFileIn && }
+ {menu.entry.isDir && onCreateFolderIn && }
{onRenameEntry && }
{onDeleteEntry && }
@@ -147,10 +153,11 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
return (
+ {entries.length === 0 &&
(空)
}
{entries.map((entry) => (