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

+27 -1
View File
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
{"GET /api/v1/tree", authed, s.tree},
{"PUT /api/v1/tree-icon", authed, s.setTreeIcon},
{"GET /api/v1/metadata", authed, s.metadata},
{"POST /api/v1/export", authed, s.export},
{"GET /api/v1/files/{path...}", authed, s.readFile},
@@ -290,6 +291,27 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"entries": entries})
}
func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) {
var body struct {
Path string `json:"path"`
Icon json.RawMessage `json:"icon"`
}
if !readJSON(w, r, &body) {
return
}
var icon *vault.TreeIcon
if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil {
writeError(w, 400, "请求格式错误:需要 icon(null 恢复默认)")
return
}
if err := s.vault.SetTreeIcon(body.Path, icon); err != nil {
vaultError(w, r, err)
return
}
s.hub.Broadcast(events.Event{Type: "tree"})
writeJSON(w, 200, map[string]bool{"ok": true})
}
const searchLimit = 50
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
@@ -355,6 +377,9 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
if rewritten {
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
} else {
// 图标提交在文件移动之后;不能仅依赖文件监听器较早的通知。
s.hub.Broadcast(events.Event{Type: "tree"})
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
@@ -461,6 +486,7 @@ func (s *Server) trashRestore(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err)
return
}
s.hub.Broadcast(events.Event{Type: "tree"})
writeJSON(w, 200, item)
}
@@ -511,7 +537,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.As(err, &conflict):
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport):
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon):
writeError(w, 400, err.Error())
case errors.Is(err, vault.ErrNotFound):
writeError(w, 404, err.Error())
+78
View File
@@ -1967,6 +1967,61 @@
}
}
}
},
"/tree-icon": {
"put": {
"summary": "设置单个文件或目录的图标,null 恢复默认",
"tags": [
"文件"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path",
"icon"
],
"properties": {
"path": {
"type": "string"
},
"icon": {
"oneOf": [
{
"$ref": "#/components/schemas/TreeIcon"
},
{
"type": "null"
}
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ok"
}
}
}
},
"400": {
"description": "非法路径或图标"
},
"404": {
"description": "文件不存在"
}
}
}
}
},
"components": {
@@ -2018,6 +2073,9 @@
"mtime": {
"type": "integer",
"description": "修改时间(Unix 毫秒)"
},
"icon": {
"$ref": "#/components/schemas/TreeIcon"
}
}
},
@@ -2155,6 +2213,26 @@
"type": "boolean"
}
}
},
"TreeIcon": {
"type": "object",
"required": [
"kind",
"value"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"emoji",
"builtin"
]
},
"value": {
"type": "string",
"description": "Emoji 字符或内置线性图标名称,最多64字节"
}
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"net/http"
"rawmark/internal/events"
"rawmark/internal/vault"
"testing"
"time"
)
func TestTreeIconAuthAndBroadcast(t *testing.T) {
f := newFixture(t)
f.write("a.md", "正文")
payload := `{"path":"a.md","icon":{"kind":"emoji","value":"📚"}}`
check := func(client *http.Client, headers map[string]string, want int) {
t.Helper()
res := f.do(client, "PUT", "/api/v1/tree-icon", payload, headers)
res.Body.Close()
if res.StatusCode != want {
t.Fatalf("设置图标状态: %d,期望 %d", res.StatusCode, want)
}
}
check(newClient(), nil, 401)
for _, scope := range []string{"read", "write"} {
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"icons","scope":"`+scope+`"}`, nil))
want := 200
if scope == "read" {
want = 403
}
check(newClient(), map[string]string{"Authorization": "Bearer " + token.Token}, want)
}
ch, cancel := f.hub.Subscribe()
defer cancel()
check(f.admin, nil, 200)
select {
case msg := <-ch:
var batch []events.Event
if err := json.Unmarshal(msg, &batch); err != nil || len(batch) != 1 || batch[0].Type != "tree" {
t.Fatalf("图标广播不符: %s %v", msg, err)
}
case <-time.After(time.Second):
t.Fatal("缺少 tree 广播")
}
tree := decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil))
if len(tree.Entries) != 1 || tree.Entries[0].Icon == nil || tree.Entries[0].Icon.Value != "📚" {
t.Fatalf("目录属性不符: %+v", tree)
}
res := f.do(f.admin, "PUT", "/api/v1/tree-icon", `{"path":"a.md","icon":null}`, nil)
res.Body.Close()
if res.StatusCode != 200 {
t.Fatal(res.StatusCode)
}
tree = decode[struct{ Entries []vault.Entry }](t, f.do(f.admin, "GET", "/api/v1/tree", "", nil))
if tree.Entries[0].Icon != nil {
t.Fatal("恢复默认后不应返回覆盖属性")
}
for _, body := range []string{`{"path":"a.md"}`, `{"path":"a.md","icon":"📚"}`, `{"path":"a.md","icon":{"kind":"builtin","value":"fake"}}`, `{"path":"../a.md","icon":null}`} {
res := f.do(f.admin, "PUT", "/api/v1/tree-icon", body, nil)
res.Body.Close()
if res.StatusCode != 400 {
t.Fatal(res.StatusCode)
}
}
}