feat(tree): persist custom node icons and animate folder expansion

This commit is contained in:
eezd committed 2026-10-24 18:13:42 +08:00
1 parent 4a8902a3f6
commit 0861be681b
18 files changed
+1056 -72

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
package vault
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func setIcon(t *testing.T, v *Vault, path string, icon *TreeIcon) {
t.Helper()
if err := v.SetTreeIcon(path, icon); err != nil {
t.Fatal(err)
}
}
func treeIcons(t *testing.T, v *Vault) map[string]TreeIcon {
t.Helper()
entries, err := v.Tree()
if err != nil {
t.Fatal(err)
}
result := map[string]TreeIcon{}
for _, entry := range entries {
if entry.Icon != nil {
result[entry.Path] = *entry.Icon
}
}
return result
}
func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) {
v := newVault(t)
if _, err := os.Stat(filepath.Join(v.root, metaDir, "tree-icons.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("未修改时不应生成图标文件", err)
}
if _, err := v.Write("folder/nested/note.md", []byte("# 正文\n"), Precondition{}); err != nil {
t.Fatal(err)
}
folder, note := TreeIcon{"emoji", "📚"}, TreeIcon{"builtin", "task"}
setIcon(t, v, "folder", &folder)
setIcon(t, v, "folder/nested/note.md", &note)
body, rev, err := v.Read("folder/nested/note.md")
if err != nil || string(body) != "# 正文\n" || rev != Rev([]byte("# 正文\n")) {
t.Fatal("图标修改了正文/版本", err)
}
v, err = Open(v.root)
if err != nil {
t.Fatal(err)
}
if _, err := v.Move("folder/nested/note.md", "folder/nested/renamed.md"); err != nil {
t.Fatal(err)
}
if _, err := v.Move("folder", "destination/moved"); err != nil {
t.Fatal(err)
}
want := map[string]TreeIcon{"destination/moved": folder, "destination/moved/nested/renamed.md": note}
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
t.Fatalf("改名/移动后图标不符: %v", got)
}
item, err := v.Delete("destination/moved")
if err != nil {
t.Fatal(err)
}
if got := treeIcons(t, v); len(got) != 0 {
t.Fatalf("删除后保留了属性: %v", got)
}
if _, err := v.Write("destination/moved/nested/renamed.md", []byte("新内容"), Precondition{}); err != nil {
t.Fatal(err)
}
if got := treeIcons(t, v); len(got) != 0 {
t.Fatalf("同名新文件继承了属性: %v", got)
}
if _, err := v.Restore(item.ID); !errors.Is(err, ErrExists) {
t.Fatal("恢复冲突应失败", err)
}
if _, err := v.Move("destination/moved", "replacement"); err != nil {
t.Fatal(err)
}
if _, err := v.Restore(item.ID); err != nil {
t.Fatal(err)
}
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
t.Fatalf("恢复后图标不符: %v", got)
}
setIcon(t, v, "destination/moved", nil)
delete(want, "destination/moved")
if got := treeIcons(t, v); !reflect.DeepEqual(got, want) {
t.Fatalf("恢复默认失败: %v", got)
}
// 原有无图标的回收站条目也可恢复。
item, err = v.Delete("replacement")
if err != nil {
t.Fatal(err)
}
if _, err := v.Restore(item.ID); err != nil {
t.Fatal(err)
}
}
func TestTreeIconValidationAndCorruptMetadata(t *testing.T) {
v := newVault(t)
if _, err := v.Write("a.md", []byte("body"), Precondition{}); err != nil {
t.Fatal(err)
}
for _, value := range []string{"📂", "👨‍👩‍👧‍👦", "👩🏽‍💻", "🇨🇳", "1️⃣", "©️", "ℹ️", "↔️", "◼️", "‼️", "〰️", "🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"} {
setIcon(t, v, "a.md", &TreeIcon{"emoji", value})
}
for _, icon := range []TreeIcon{{"url", "https://example.com"}, {"builtin", "__proto__"}, {"builtin", "folder note"}, {"emoji", ""}, {"emoji", "hello"}, {"emoji", "<svg>"}, {"emoji", "\u200d"}, {"emoji", strings.Repeat("📂", 17)}} {
if err := v.SetTreeIcon("a.md", &icon); !errors.Is(err, ErrInvalidIcon) {
t.Fatalf("应拒绝 %+v: %v", icon, err)
}
}
if err := v.SetTreeIcon("../a.md", nil); !errors.Is(err, ErrInvalidPath) {
t.Fatal(err)
}
if err := v.SetTreeIcon("missing.md", nil); !errors.Is(err, ErrNotFound) {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(v.root, "a.md"), filepath.Join(v.root, "link.md")); err != nil {
t.Fatal(err)
}
if err := v.SetTreeIcon("link.md", nil); !errors.Is(err, ErrInvalidPath) {
t.Fatal("不应设置符号链接图标", err)
}
for _, data := range []string{"{", "null", `{"../a.md":{"kind":"emoji","value":"📂"}}`, `{"a.md":{"kind":"builtin","value":"absent"}}`} {
if err := os.WriteFile(filepath.Join(v.root, metaDir, "tree-icons.json"), []byte(data), 0644); err != nil {
t.Fatal(err)
}
if _, err := v.Tree(); err == nil {
t.Fatal("损坏元数据不应静默清空")
}
if _, err := v.Move("a.md", "b.md"); err == nil {
t.Fatal("元数据异常时应停止移动")
}
if _, err := v.Delete("a.md"); err == nil {
t.Fatal("元数据异常时应停止删除")
}
if _, _, err := v.Read("a.md"); err != nil {
t.Fatal("原件应保持可用", err)
}
}
}
func TestTreeIconWriteFailureRollsBack(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限")
}
for _, operation := range []string{"set", "move", "delete", "restore"} {
t.Run(operation, func(t *testing.T) {
v := newVault(t)
if _, err := v.Write("a.md", []byte("[link](other.md)"), Precondition{}); err != nil {
t.Fatal(err)
}
legacyAsset(t, v, "a.md", "p.png", "image")
icon := TreeIcon{"emoji", "📚"}
setIcon(t, v, "a.md", &icon)
var item TrashItem
var err error
if operation == "restore" {
item, err = v.Delete("a.md")
if err != nil {
t.Fatal(err)
}
}
before, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil {
t.Fatal(err)
}
defer os.Chmod(filepath.Join(v.root, metaDir), 0755)
switch operation {
case "set":
err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"})
case "move":
_, err = v.Move("a.md", "new/b.md")
case "delete":
_, err = v.Delete("a.md")
case "restore":
_, err = v.Restore(item.ID)
}
if err == nil {
t.Fatal("图标元数据写失败却返回成功")
}
after, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
if err != nil || string(before) != string(after) {
t.Fatal("原有元数据被改变", err)
}
if operation == "restore" {
if _, err := v.Stat("a.md"); !errors.Is(err, ErrNotFound) {
t.Fatal("恢复应撤回", err)
}
metadata, err := v.readTrashMetadata(item.ID)
if err != nil || metadata.Icons["a.md"] != icon {
t.Fatal("回收站图标丢失", err)
}
} else {
body, _, err := v.Read("a.md")
if err != nil || string(body) != "[link](other.md)" {
t.Fatal("原件或正文未回滚", err)
}
if _, err := os.Stat(filepath.Join(v.root, "a.assets")); err != nil {
t.Fatal("附件未回滚", err)
}
if _, err := v.Stat("new/b.md"); !errors.Is(err, ErrNotFound) {
t.Fatal("移动目标未撤回", err)
}
}
})
}
}