From 5514a1e46585f1ac22b3c5131ba17d3e44aed694 Mon Sep 17 00:00:00 2001 From: eezd Date: Wed, 4 Nov 2026 18:20:16 +0800 Subject: [PATCH] feat: synchronize pinned tabs across devices --- docs/api.md | 6 + docs/api.zh-CN.md | 6 + internal/api/api_test.go | 20 ++ internal/api/maintenance_test.go | 12 +- internal/api/openapi.json | 13 +- internal/store/backup.go | 15 +- internal/store/backup_test.go | 28 ++ web/src/pages/workspace/components/TabBar.vue | 4 +- web/src/pages/workspace/workspace.test.ts | 246 +++++++++++++++++- web/src/pages/workspace/workspace.ts | 118 +++++++-- web/src/pinia/stores/settings.test.ts | 11 + web/src/pinia/stores/settings.ts | 4 +- 12 files changed, 454 insertions(+), 29 deletions(-) diff --git a/docs/api.md b/docs/api.md index 9767016..063b1a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -53,6 +53,12 @@ Send `Accept-Language: en` or `Accept-Language: zh-CN` to select the language of Account language is the `locale` field on `/settings`, supporting only `en` and `zh-CN`. PATCH with `null` removes the override and restores default English. The field is exported, imported, and synchronized with other settings. The sign-in page's temporary choice does not write to the account. Request headers affect only the current response, not account language. +## Pinned tabs + +The `/settings` field `pinnedTabs` is an array of Markdown paths relative to the vault. The browser saves pin/unpin actions to the server and restores existing pinned notes on other devices, including after an event connection reconnects. An explicit empty array means no pinned tabs; if the field is absent, the browser migrates its legacy local pins once. Resetting settings stores an empty array so older device caches cannot restore cleared pins. Concurrent replacements of this list use the last server write. + +Unpinned tabs and the active tab remain local to each device. Remote unpinning keeps local tabs and unsaved edits open. Explicitly closing a pinned tab also removes its server pin. Renaming notes or folders through the workspace updates pinned paths; unavailable paths are skipped when opening tabs. + ## Browser sign-in There are no usernames, registration, or roles. Before initialization, `POST /auth/setup` sets the password using `{ password }` and returns a session. An initialized instance cannot be initialized again. diff --git a/docs/api.zh-CN.md b/docs/api.zh-CN.md index b44be3f..434c800 100644 --- a/docs/api.zh-CN.md +++ b/docs/api.zh-CN.md @@ -53,6 +53,12 @@ curl --fail-with-body \ 账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。 +## 固定标签 + +`/settings` 的 `pinnedTabs` 字段保存相对笔记库的 Markdown 路径数组。浏览器将固定和取消固定写入服务器,其他设备打开时及事件连接重连后会恢复仍存在的固定笔记。显式空数组表示没有固定标签;字段尚不存在时,浏览器会将旧的本地固定标签迁移一次。重置设置会保存空数组,避免其他设备的旧缓存恢复已清空的固定记录。多个设备同时替换该列表时,以服务器最后一次写入为准。 + +未固定标签和当前选中的标签仍属于各设备本地状态。远程取消固定不会关闭本机标签或丢弃未保存内容;主动关闭固定标签也会移除服务器上的固定记录。通过工作区重命名笔记或目录时会更新固定路径,打开标签时跳过不存在的路径。 + ## 浏览器登录 实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7f5dd4f..d2c10a7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -404,6 +404,26 @@ func TestSettingsMerge(t *testing.T) { } } +func TestPinnedTabsSharedAcrossSessions(t *testing.T) { + f := newFixture(t) + other := newClient() + f.login(other, "password123") + f.do(f.admin, "PATCH", "/api/v1/settings", `{"pinnedTabs":["a.md","目录/b.md"]}`, nil).Body.Close() + got := decode[struct{ PinnedTabs []string }](t, f.do(other, "GET", "/api/v1/settings", "", nil)) + if len(got.PinnedTabs) != 2 || got.PinnedTabs[0] != "a.md" || got.PinnedTabs[1] != "目录/b.md" { + t.Fatalf("另一会话未读取到固定标签: %v", got.PinnedTabs) + } + f.do(other, "PATCH", "/api/v1/settings", `{"pinnedTabs":[]}`, nil).Body.Close() + again := decode[struct{ PinnedTabs []string }](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)) + if again.PinnedTabs == nil || len(again.PinnedTabs) != 0 { + t.Fatalf("取消固定必须保存为空列表: %v", again.PinnedTabs) + } + stored, ok, err := f.store.Setting(settingsKey) + if err != nil || !ok || !strings.Contains(stored, `"pinnedTabs":[]`) { + t.Fatalf("固定标签未持久化到数据库: %q, %v", stored, err) + } +} + func TestChangePassword(t *testing.T) { f := newFixture(t) other := newClient() diff --git a/internal/api/maintenance_test.go b/internal/api/maintenance_test.go index 672d696..6dd39aa 100644 --- a/internal/api/maintenance_test.go +++ b/internal/api/maintenance_test.go @@ -156,17 +156,19 @@ func TestSettingsPackageRoundTripMergesResourcesAndPreservesAccount(t *testing.T func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) { f := newFixture(t) f.write("note.md", "source") - f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0}`) + f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0,"pinnedTabs":["note.md"]}`) f.store.SetSetting(pluginEnabledKey, `{"example":true}`) f.store.SetSetting("plugin.data.example", `{"important":"content"}`) if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 { t.Fatal(status) } assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil)) - for _, key := range []string{settingsKey, pluginEnabledKey} { - if _, ok, err := f.store.Setting(key); err != nil || ok { - t.Fatalf("未重置 %s", key) - } + if _, ok, err := f.store.Setting(pluginEnabledKey); err != nil || ok { + t.Fatal("插件启用状态未重置") + } + settings := decode[map[string]json.RawMessage](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)) + if len(settings) != 1 || string(settings["pinnedTabs"]) != "[]" { + t.Fatalf("重置必须清空固定标签并阻止旧设备再次迁移: %v", settings) } if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` { t.Fatal("插件数据丢失") diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 4002932..4ee12c8 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -481,7 +481,7 @@ "bearer": [] } ], - "description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.", + "description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. pinnedTabs stores vault-relative Markdown paths shared across devices; an empty array explicitly clears pins, while an absent key allows the browser to migrate legacy local pins. Concurrent replacements use the last server write. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.", "requestBody": { "required": true, "content": { @@ -490,6 +490,16 @@ "type": "object", "additionalProperties": true, "properties": { + "pinnedTabs": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Ordered vault-relative Markdown paths. Empty means no pins; null removes the setting." + }, "trashRetentionDays": { "type": [ "integer", @@ -3789,6 +3799,7 @@ "/maintenance/settings/reset": { "post": { "summary": "Reset confirmed preferences while retaining plugin storage", + "description": "Reset application preferences and plugin activation while preserving plugin data and credentials. Store pinnedTabs as an explicit empty array so legacy browser caches cannot migrate cleared pins back to the server.", "tags": [ "Maintenance" ], diff --git a/internal/store/backup.go b/internal/store/backup.go index 04c9bec..41d25bf 100644 --- a/internal/store/backup.go +++ b/internal/store/backup.go @@ -56,8 +56,19 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error { return tx.Commit() } func (s *Store) ResetPreferences() error { - _, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`) - return err + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`); err != nil { + return err + } + // An explicit empty list prevents older devices from migrating their local pins after a reset. + if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ('app.settings',?)`, `{"pinnedTabs":[]}`); err != nil { + return err + } + return tx.Commit() } func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err } diff --git a/internal/store/backup_test.go b/internal/store/backup_test.go index b0d86ad..67c4a8a 100644 --- a/internal/store/backup_test.go +++ b/internal/store/backup_test.go @@ -48,3 +48,31 @@ func TestOpenExistingUsesNativeEscapedPathWithoutCreating(t *testing.T) { t.Fatalf("missing database was created: %v", err) } } + +func TestResetPreferencesRollsBackIfPinResetFails(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "rawmark.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + original := map[string]string{ + "app.settings": `{"fontSize":24,"pinnedTabs":["note.md"]}`, + "plugins.enabled": `{"example":true}`, + } + for key, value := range original { + if err := st.SetSetting(key, value); err != nil { + t.Fatal(err) + } + } + if _, err := st.db.Exec(`CREATE TRIGGER reject_pin_reset BEFORE INSERT ON settings WHEN NEW.key='app.settings' BEGIN SELECT RAISE(ABORT,'intentional reset failure'); END`); err != nil { + t.Fatal(err) + } + if err := st.ResetPreferences(); err == nil { + t.Fatal("固定标签重置失败必须返回错误") + } + for key, value := range original { + if got, ok, err := st.Setting(key); err != nil || !ok || got != value { + t.Fatalf("重置失败未回滚 %s: %q, %v", key, got, err) + } + } +} diff --git a/web/src/pages/workspace/components/TabBar.vue b/web/src/pages/workspace/components/TabBar.vue index f700b67..5dfe2d9 100644 --- a/web/src/pages/workspace/components/TabBar.vue +++ b/web/src/pages/workspace/components/TabBar.vue @@ -6,7 +6,7 @@ import { t as translate } from '@@/i18n/runtime' import { commandTitle } from '@@/utils/commands' import { noteTitle } from '@@/utils/paths' import { nextTick, ref } from 'vue' -import { closeTab, createNote, ws } from '../workspace' +import { closeTab, createNote, toggleTabPin, ws } from '../workspace' // Drag sorting: dropping on a tab's left half inserts before it; its right half inserts after it. const TAB_TYPE = 'application/x-rawmark-tab' @@ -113,7 +113,7 @@ function closeRight(path: string) {