diff --git a/README.md b/README.md
index 784ff38..f726f95 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ docker run -d --name rawmark \
| `RAWMARK_VAULT` | `$RAWMARK_DATA/vault` | 笔记仓库目录,可指向已有的笔记文件夹 |
| `RAWMARK_ADDR` | `:8080` | 监听地址 |
+文件树默认使用 📂 / 📄,可点击图标或右键“修改图标”,选择表情或内置线性图标。自定义属性存于仓库的 `.rawmark/tree-icons.json`,随应用内移动、重命名及回收站恢复保留,不改动或导出到 Markdown 正文;外部文件操作不保证跟随。
+
## 开发
需要 Go 1.27+ 与 Node 24+。开发时不必构建前端或 Docker 镜像,一键启动:
diff --git a/internal/api/api.go b/internal/api/api.go
index c4df570..0e3053c 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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())
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index 9b34c47..5baaff3 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -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字节"
+ }
+ }
}
}
}
diff --git a/internal/api/tree_icons_test.go b/internal/api/tree_icons_test.go
new file mode 100644
index 0000000..df34572
--- /dev/null
+++ b/internal/api/tree_icons_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/vault/tree_icons.go b/internal/vault/tree_icons.go
new file mode 100644
index 0000000..c72a929
--- /dev/null
+++ b/internal/vault/tree_icons.go
@@ -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"`
+}
diff --git a/internal/vault/tree_icons_test.go b/internal/vault/tree_icons_test.go
new file mode 100644
index 0000000..2d3dd68
--- /dev/null
+++ b/internal/vault/tree_icons_test.go
@@ -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", ¬e)
+ 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", "
diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue
index 8261a01..cc41b4e 100644
--- a/web/src/app/Sidebar.vue
+++ b/web/src/app/Sidebar.vue
@@ -3,8 +3,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import { commandTitle } from './commands'
-import { dropDirectory, tree } from './tree'
+import { dropDirectory, tree, treeIconTarget } from './tree'
import TreeNode from './TreeNode.vue'
+import TreeIconPicker from './TreeIconPicker.vue'
import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace'
import { sidebarViews } from '../plugins/registry'
@@ -89,6 +90,7 @@ onUnmounted(() => {
window.removeEventListener('blur', clearDrop)
window.removeEventListener('keydown', onDragKeydown)
clearDrop()
+ treeIconTarget.value = null
})
@@ -96,8 +98,8 @@ onUnmounted(() => {
+
-
+ $el.setAttribute('inert', '')" @leave-cancelled="($el: Element) => $el.removeAttribute('inert')">
+
+
diff --git a/web/src/app/api.ts b/web/src/app/api.ts
index d67ca96..dec140b 100644
--- a/web/src/app/api.ts
+++ b/web/src/app/api.ts
@@ -1,10 +1,15 @@
+import type { IconName } from './icons'
+
// REST API 客户端,与服务端 internal/api 对应
+export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName }
+
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
+ icon?: TreeIcon
}
export interface ImportResult {
@@ -144,6 +149,7 @@ export const api = {
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
+ setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }),
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
diff --git a/web/src/app/app.css b/web/src/app/app.css
index ef26a3c..1b20d4e 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -156,7 +156,7 @@ button {
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
-.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
+.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-tree-chevron.open { transform: rotate(90deg); }
.rm-tree-chevron.hidden { visibility: hidden; }
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -631,7 +631,7 @@ button {
/* 轻玻璃工作台 */
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
-.rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; }
+.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; }
.rm-tree-label:hover { background: var(--rm-hover); }
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
@@ -713,3 +713,14 @@ button {
@media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
+
+/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
+.rm-tree-children { display: grid; grid-template-rows: 1fr; }
+.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; }
+.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
+.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; }
+.rm-tree-expand-leave-active { pointer-events: none; }
+.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; }
+.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
+.rm-tree-icon:hover { background: var(--rm-hover); }
+@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } }
diff --git a/web/src/app/icons.ts b/web/src/app/icons.ts
new file mode 100644
index 0000000..ebac84f
--- /dev/null
+++ b/web/src/app/icons.ts
@@ -0,0 +1,43 @@
+// 线性图标(24×24 视框,描边绘制)
+export const iconPaths = {
+ plus: 'M12 5v14M5 12h14',
+ eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
+ expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
+ graph: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM10 8l-4 8M14 8l4 8',
+ calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
+ tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
+ copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
+ task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
+ quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
+ undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
+ redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
+ print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
+ grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
+
+ menu: 'M4 6h16M4 12h16M4 18h16',
+ noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
+ import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
+ folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
+ folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
+ image: 'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 16l6-6 12 11M14 8h.01',
+ note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
+ chevron: 'm9 6 6 6-6 6',
+ more: 'M12 6h.01M12 12h.01M12 18h.01',
+ close: 'M18 6 6 18M6 6l12 12',
+ pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
+ trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
+ logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
+ restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
+ edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
+ search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
+ link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
+ key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3',
+ settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
+ palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01',
+ user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
+ outline: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
+ source: 'm8 6-6 6 6 6m8-12 6 6-6 6',
+ command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
+ info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 11v6M12 7h.01',
+}
+export type IconName = keyof typeof iconPaths
diff --git a/web/src/app/tree.ts b/web/src/app/tree.ts
index 9f7ef05..2ec9d06 100644
--- a/web/src/app/tree.ts
+++ b/web/src/app/tree.ts
@@ -1,5 +1,5 @@
-import { computed, reactive, ref, watch } from 'vue'
-import type { Entry } from './api'
+import { computed, reactive, ref, shallowRef, watch } from 'vue'
+import type { Entry, TreeIcon } from './api'
import { basename, dirname, hiddenAssetPath } from './paths'
import { rangePaths } from './selection'
import { ws } from './workspace'
@@ -8,6 +8,7 @@ export interface TreeNode {
name: string
path: string
dir: boolean
+ icon?: TreeIcon
children: TreeNode[]
}
@@ -18,7 +19,7 @@ function build(entries: Entry[]): TreeNode[] {
const dirs = new Map()
// 先按路径排序,保证父目录先于子项出现
for (const e of [...entries].sort((a, b) => a.path.length - b.path.length)) {
- const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, children: [] }
+ const node: TreeNode = { name: basename(e.path), path: e.path, dir: e.dir, icon: e.icon, children: [] }
if (e.dir) dirs.set(e.path, node)
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
}
@@ -32,6 +33,8 @@ function build(entries: Entry[]): TreeNode[] {
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
+export const treeIconTarget = shallowRef<{ path: string; anchor: HTMLElement } | null>(null)
+
// null 表示无接收目标,空字符串表示根目录;文件行指向它的直接父目录。
export const dropDirectory = ref(null)
diff --git a/web/src/app/treeIconCatalog.ts b/web/src/app/treeIconCatalog.ts
new file mode 100644
index 0000000..35d6b5f
--- /dev/null
+++ b/web/src/app/treeIconCatalog.ts
@@ -0,0 +1,48 @@
+import type { Gemoji } from 'gemoji'
+import type { IconName } from './icons'
+
+export const iconLabels: Record = {
+ 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: '信息',
+}
+
+export const emojiCategories = [
+ { id: 'Smileys & Emotion', label: '表情', icon: '😀' },
+ { id: 'People & Body', label: '人物', icon: '👋' },
+ { id: 'Animals & Nature', label: '自然', icon: '🌿' },
+ { id: 'Food & Drink', label: '食物', icon: '🍎' },
+ { id: 'Travel & Places', label: '旅行', icon: '🚀' },
+ { id: 'Activities', label: '活动', icon: '⚽' },
+ { id: 'Objects', label: '物品', icon: '💡' },
+ { id: 'Symbols', label: '符号', icon: '❤️' },
+ { id: 'Flags', label: '旗帜', icon: '🏳️' },
+]
+
+// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
+const keywords: Record = {
+ '📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录',
+ '📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书',
+ '📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本',
+ '📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务',
+ '⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法',
+ '📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮',
+ '🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册',
+ '🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏',
+ '🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标',
+ '🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花',
+ '☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物',
+ '☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好',
+ '⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习',
+}
+
+export function emojiLabel(emoji: Gemoji): string {
+ return keywords[emoji.emoji] ?? emoji.description
+}
+
+export function matchesEmoji(emoji: Gemoji, query: string): boolean {
+ return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
+}
diff --git a/web/src/app/treeIcons.test.ts b/web/src/app/treeIcons.test.ts
new file mode 100644
index 0000000..3669c74
--- /dev/null
+++ b/web/src/app/treeIcons.test.ts
@@ -0,0 +1,132 @@
+// @vitest-environment happy-dom
+import { createApp, nextTick, type App } from 'vue'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import Sidebar from './Sidebar.vue'
+import { expanded, selectedPaths, treeIconTarget } from './tree'
+import * as workspace from './workspace'
+import { api } from './api'
+
+let app: App
+let host: HTMLElement
+beforeEach(async () => {
+ vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue()
+ vi.spyOn(workspace, 'openNote').mockResolvedValue()
+ vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
+ localStorage.removeItem('rawmark.recent-tree-icons')
+ Object.assign(workspace.ws, { searchOpen: false, active: '', image: null, renaming: null, entries: [
+ { path: 'folder', dir: true }, { path: 'folder/a.md', dir: false },
+ { path: 'folder/b.md', dir: false }, { path: 'folder/c.md', dir: false },
+ ].map(e => ({ ...e, size: 0, mtime: 0 })) })
+ expanded.clear()
+ selectedPaths.clear()
+ treeIconTarget.value = null
+ host = document.createElement('div')
+ document.body.append(host)
+ app = createApp(Sidebar)
+ app.mount(host)
+ await nextTick()
+})
+afterEach(() => { app.unmount(); host.remove(); vi.restoreAllMocks() })
+const row = (name: string) => [...host.querySelectorAll('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent === name)!
+const click = async (element: Element, init: MouseEventInit = {}) => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })); await nextTick() }
+const button = (name: string) => document.querySelector(`[aria-label="${name}"]`)!
+async function picker() {
+ await click(row('folder').querySelector('.rm-tree-icon')!)
+ await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull())
+}
+
+describe('文件树图标操作', () => {
+ it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => {
+ expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent).toBe('文件目录')
+ expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂')
+ await click(row('folder'))
+ expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄')
+ await click(row('a'), { ctrlKey: true })
+ const selected = [...selectedPaths]
+ await picker()
+ expect(expanded.has('folder')).toBe(true)
+ expect([...selectedPaths]).toEqual(selected)
+ expect(workspace.openNote).not.toHaveBeenCalled()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ await nextTick()
+ expect(treeIconTarget.value).toBeNull()
+ expect(document.activeElement).toBe(row('folder').querySelector('.rm-tree-icon'))
+ })
+
+ it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => {
+ await picker()
+ const input = button('搜索图标') as HTMLInputElement
+ input.value = '文件夹'
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull())
+ await click(button('文件夹 目录 打开'))
+ await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
+ expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' })
+ expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }])
+ })
+
+ it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => {
+ vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读'))
+ await picker()
+ await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
+ await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读'))
+ expect(treeIconTarget.value?.path).toBe('folder')
+ expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull()
+ await click([...document.querySelectorAll('button')].find(e => e.textContent === '恢复默认图标')!)
+ await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
+ expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null)
+ })
+
+ it('线性图标可搜索,右键入口选择同一节点', async () => {
+ row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
+ await nextTick()
+ await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent === '修改图标')!)
+ await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent === '线性图标')!)
+ const input = button('搜索图标') as HTMLInputElement
+ input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
+ expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1)
+ await click(button('任务'))
+ expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' })
+ })
+
+ it('Ctrl 和 Shift 仍按可见行选择,快速展开/收起保留最新状态', async () => {
+ await click(row('folder'))
+ await click(row('a'))
+ await click(row('c'), { shiftKey: true })
+ expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/b.md', 'folder/c.md'])
+ await click(row('b'), { ctrlKey: true })
+ expect([...selectedPaths]).toEqual(['folder/a.md', 'folder/c.md'])
+ await click(row('folder'))
+ await click(row('folder'))
+ await vi.waitFor(() => expect(row('c')).toBeDefined())
+ expect(expanded.has('folder')).toBe(true)
+ await click(row('folder'))
+ await vi.waitFor(() => expect(row('a')).toBeUndefined())
+ expect(selectedPaths.has('folder/a.md')).toBe(false)
+ })
+
+ it('从图标按住拖动仍使用整行的拖放契约,不能误开选择框', async () => {
+ const setData = vi.fn()
+ const event = new Event('dragstart', { bubbles: true, cancelable: true })
+ Object.defineProperty(event, 'dataTransfer', { value: { setData, effectAllowed: '' } })
+ row('folder').querySelector('.rm-tree-icon')!.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(false)
+ expect(setData).toHaveBeenCalledExactlyOnceWith('application/x-rawmark-path', 'folder')
+ expect(treeIconTarget.value).toBeNull()
+ })
+
+ it('持久属性直接来自目录 API,不维护第二份图标映射', async () => {
+ workspace.ws.entries[0].icon = { kind: 'builtin', value: 'task' }
+ await nextTick()
+ expect(row('folder').querySelector('.rm-tree-icon svg')).not.toBeNull()
+ workspace.ws.entries[0].icon = { kind: 'emoji', value: '📚' }
+ await nextTick()
+ expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📚')
+ })
+})
+
+it('图标请求沿用认证、禁缓存和 null 重置契约', async () => {
+ const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
+ await api.setTreeIcon('folder/a.md', null)
+ expect(fetch).toHaveBeenCalledWith('/api/v1/tree-icon', expect.objectContaining({ method: 'PUT', credentials: 'same-origin', cache: 'no-store', body: '{"path":"folder/a.md","icon":null}' }))
+})
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index 633f35a..bb767d0 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -8,7 +8,7 @@ import { editorCommands } from '../editor/shortcuts'
import { refreshImages } from '../editor/livepreview'
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from '../editor'
import { handleShortcut, registerCommand } from './commands'
-import { api, ApiError, encodePath, type Entry } from './api'
+import { api, ApiError, encodePath, type Entry, type TreeIcon } from './api'
import { drafts } from './drafts'
import { refreshMetadata } from './metadata'
import { connectEvents, type VaultEvent } from './events'
@@ -663,6 +663,12 @@ async function withOp(fn: () => Promise): Promise {
}
}
+export async function saveTreeIcon(path: string, icon: TreeIcon | null) {
+ await withOp(() => api.setTreeIcon(path, icon))
+ try { await refreshTree() }
+ catch (err) { toast('图标已保存,目录刷新失败:' + errorText(err)) }
+}
+
export async function createNote(dir: string) {
const path = uniqueName(dir, '未命名', NOTE_EXT)
ws.renaming = path