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

+124
View File
@@ -0,0 +1,124 @@
package vault
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
)
// TreeIcon 是文件树的显示属性,不参与 Markdown 内容或版本计算。
type TreeIcon struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
var ErrInvalidIcon = errors.New("非法图标")
const builtinIcons = " plus eye expand graph calendar tag copy task quote undo redo print grid menu noteAdd import folderAdd folder image note chevron more close pin trash logout restore edit search link key settings palette user outline source command info "
var emojiPattern = regexp.MustCompile(`^[\p{So}\p{Sk}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{200D}\x{FE0F}\x{20E3}0-9#*\x{E0020}-\x{E007F}]+$`)
var emojiBase = regexp.MustCompile(`[\p{So}\p{Sm}\x{2139}\x{203C}\x{2049}\x{3030}\x{303D}\x{20E3}]`)
func (icon TreeIcon) valid() bool {
switch icon.Kind {
case "builtin":
return icon.Value != "" && !strings.ContainsAny(icon.Value, " \t\r\n") && strings.Contains(builtinIcons, " "+icon.Value+" ")
case "emoji":
return utf8.ValidString(icon.Value) && len(icon.Value) <= 64 && emojiPattern.MatchString(icon.Value) && emojiBase.MatchString(icon.Value)
default:
return false
}
}
func (v *Vault) readTreeIcons() (map[string]TreeIcon, error) {
data, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json"))
if errors.Is(err, fs.ErrNotExist) {
return map[string]TreeIcon{}, nil
}
if err != nil {
return nil, err
}
var icons map[string]TreeIcon
if err := json.Unmarshal(data, &icons); err != nil {
return nil, fmt.Errorf("文件树图标元数据损坏: %w", err)
}
if icons == nil {
return nil, errors.New("文件树图标元数据必须是对象")
}
for p, icon := range icons {
if _, err := CleanPath(p); err != nil || !icon.valid() {
return nil, errors.New("文件树图标元数据含非法路径或图标")
}
}
return icons, nil
}
func (v *Vault) writeTreeIcons(icons map[string]TreeIcon) error {
data, err := json.Marshal(icons)
if err != nil {
return err
}
return atomicWrite(filepath.Join(v.root, metaDir, "tree-icons.json"), data)
}
func (v *Vault) SetTreeIcon(rel string, icon *TreeIcon) error {
if icon != nil && !icon.valid() {
return ErrInvalidIcon
}
p, err := v.abs(rel)
if err != nil {
return err
}
v.mu.Lock()
defer v.mu.Unlock()
info, err := os.Lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
if err != nil {
return err
}
if !info.IsDir() && (!info.Mode().IsRegular() || (!IsNote(rel) && !IsImage(rel))) {
return ErrInvalidPath
}
icons, err := v.readTreeIcons()
if err != nil {
return err
}
if icon == nil {
if _, ok := icons[rel]; !ok {
return nil
}
delete(icons, rel)
} else {
if icons[rel] == *icon {
return nil
}
icons[rel] = *icon
}
return v.writeTreeIcons(icons)
}
// 从所属子树移出属性,供移动和回收站操作在原有写锁内提交。
func takeTreeIcons(icons map[string]TreeIcon, root string, directory bool) map[string]TreeIcon {
taken := map[string]TreeIcon{}
for p, icon := range icons {
if p == root || directory && strings.HasPrefix(p, root+"/") {
taken[p] = icon
delete(icons, p)
}
}
return taken
}
// 回收站保留属性,但普通回收站 API 不暴露内部元数据。
type trashMetadata struct {
TrashItem
Icons map[string]TreeIcon `json:"icons,omitempty"`
}
+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)
}
}
})
}
}
+84 -13
View File
@@ -54,6 +54,7 @@ type Entry struct {
Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒
Info fs.FileInfo `json:"-"`
Icon *TreeIcon `json:"icon,omitempty"`
}
type TrashItem struct {
@@ -306,12 +307,19 @@ func (v *Vault) Tree() ([]Entry, error) {
if err != nil {
return nil, err
}
icons, err := v.readTreeIcons()
if err != nil {
return nil, err
}
visible := []Entry{}
for _, e := range entries {
if e.Dir {
e.Size = 0
}
if e.Dir || IsImage(e.Path) || (IsNote(e.Path) && !strings.Contains(e.Path, AssetsExt+"/")) {
if icon, ok := icons[e.Path]; ok {
e.Icon = &icon
}
visible = append(visible, e)
}
}
@@ -396,6 +404,15 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
if _, err := os.Lstat(dst); err == nil {
return false, ErrExists
}
icons, err := v.readTreeIcons()
if err != nil {
return false, err
}
movedIcons := takeTreeIcons(icons, from, info.IsDir())
iconsChanged := len(movedIcons) > 0
for p, icon := range movedIcons {
icons[movedPath(p, from, to, info.IsDir())] = icon
}
srcAssets, dstAssets := "", ""
if isNote {
if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
@@ -405,6 +422,13 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
}
}
}
if srcAssets != "" {
attached := takeTreeIcons(icons, AssetsDir(from), true)
iconsChanged = iconsChanged || len(attached) > 0
for p, icon := range attached {
icons[movedPath(p, AssetsDir(from), AssetsDir(to), true)] = icon
}
}
var images []imageTransfer
var imageTargets map[string]string
defer func() {
@@ -486,6 +510,9 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
images[i].removed = true
}
}
if iconsChanged {
err = v.writeTreeIcons(icons)
}
return rewritten, err
}
@@ -531,16 +558,32 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
item.Assets = filepath.Base(AssetsDir(p))
}
}
icons, err := v.readTreeIcons()
if err != nil {
return TrashItem{}, err
}
metadata := trashMetadata{TrashItem: item, Icons: takeTreeIcons(icons, rel, item.Dir)}
if item.Assets != "" {
for p, icon := range takeTreeIcons(icons, AssetsDir(rel), true) {
metadata.Icons[p] = icon
}
}
dir := filepath.Join(v.trash, item.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return TrashItem{}, err
}
noteMoved := false
noteMoved, assetsMoved := false, false
defer func() {
if err == nil {
return
}
rollbackFailed := false
if assetsMoved {
if rollback := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(p)); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚回收站图片失败: %w", rollback))
rollbackFailed = true
}
}
if noteMoved {
if rollback := os.Rename(filepath.Join(dir, filepath.Base(p)), p); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚回收站笔记失败: %w", rollback))
@@ -554,7 +597,7 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
}
}
}()
meta, _ := json.Marshal(item)
meta, _ := json.Marshal(metadata)
if err = atomicWrite(filepath.Join(dir, "meta.json"), meta); err != nil {
return item, err
}
@@ -567,25 +610,39 @@ func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
return item, fmt.Errorf("移动图片目录到回收站失败: %w", err)
}
}
return item, nil
assetsMoved = item.Assets != ""
if len(metadata.Icons) > 0 {
err = v.writeTreeIcons(icons)
}
return item, err
}
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
metadata, err := v.readTrashMetadata(id)
return metadata.TrashItem, err
}
func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) {
if !trashIDPattern.MatchString(id) {
return TrashItem{}, ErrInvalidPath
return trashMetadata{}, ErrInvalidPath
}
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
if errors.Is(err, fs.ErrNotExist) {
return TrashItem{}, ErrNotFound
return trashMetadata{}, ErrNotFound
}
if err != nil {
return TrashItem{}, err
return trashMetadata{}, err
}
var item TrashItem
if err := json.Unmarshal(data, &item); err != nil {
return TrashItem{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
var metadata trashMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
}
return item, nil
for p, icon := range metadata.Icons {
if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) {
return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id)
}
}
return metadata, nil
}
func (v *Vault) TrashList() ([]TrashItem, error) {
@@ -609,7 +666,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) {
}
func (v *Vault) Restore(id string) (item TrashItem, err error) {
item, err = v.readTrashItem(id)
metadata, err := v.readTrashMetadata(id)
item = metadata.TrashItem
if err != nil {
return TrashItem{}, err
}
@@ -619,6 +677,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
}
v.mu.Lock()
defer v.mu.Unlock()
icons, err := v.readTreeIcons()
if err != nil {
return TrashItem{}, err
}
for p, icon := range metadata.Icons {
icons[p] = icon
}
if _, err := os.Lstat(dst); err == nil {
return TrashItem{}, ErrExists
}
@@ -645,7 +710,7 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
err = errors.Join(err, rollback)
return
}
meta, _ := json.Marshal(item)
meta, _ := json.Marshal(metadata)
if rollback := atomicWrite(filepath.Join(dir, "meta.json"), meta); rollback != nil {
err = errors.Join(err, rollback)
}
@@ -666,7 +731,13 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) {
assetsMoved = true
}
cleaning = true
return item, os.RemoveAll(dir)
if err = os.RemoveAll(dir); err != nil {
return item, err
}
if len(metadata.Icons) > 0 {
err = v.writeTreeIcons(icons)
}
return item, err
}
func (v *Vault) Purge(id string) error {