feat: synchronize pinned tabs across devices
This commit is contained in:
1 parent
403824eccb
commit
5514a1e465
12 files changed
+449
-24
No files matched your search
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ curl --fail-with-body \
|
|||||||
|
|
||||||
账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。
|
账户语言位于 `/settings` 的 `locale` 字段,仅支持 `en`、`zh-CN`;PATCH 传 `null` 删除覆盖,恢复默认英文。该字段与其他设置一同导出、导入和同步;登录页的临时选择不写入账户。请求头只影响当前响应,不修改账户语言。
|
||||||
|
|
||||||
|
## 固定标签
|
||||||
|
|
||||||
|
`/settings` 的 `pinnedTabs` 字段保存相对笔记库的 Markdown 路径数组。浏览器将固定和取消固定写入服务器,其他设备打开时及事件连接重连后会恢复仍存在的固定笔记。显式空数组表示没有固定标签;字段尚不存在时,浏览器会将旧的本地固定标签迁移一次。重置设置会保存空数组,避免其他设备的旧缓存恢复已清空的固定记录。多个设备同时替换该列表时,以服务器最后一次写入为准。
|
||||||
|
|
||||||
|
未固定标签和当前选中的标签仍属于各设备本地状态。远程取消固定不会关闭本机标签或丢弃未保存内容;主动关闭固定标签也会移除服务器上的固定记录。通过工作区重命名笔记或目录时会更新固定路径,打开标签时跳过不存在的路径。
|
||||||
|
|
||||||
## 浏览器登录
|
## 浏览器登录
|
||||||
|
|
||||||
实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。
|
实例没有用户名、注册或角色系统。尚未初始化时,`POST /auth/setup` 使用 `{ password }` 设置密码并返回会话;已经初始化的实例不能再次初始化。
|
||||||
|
|||||||
@@ -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) {
|
func TestChangePassword(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
other := newClient()
|
other := newClient()
|
||||||
|
|||||||
@@ -156,17 +156,19 @@ func TestSettingsPackageRoundTripMergesResourcesAndPreservesAccount(t *testing.T
|
|||||||
func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) {
|
func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
f.write("note.md", "source")
|
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(pluginEnabledKey, `{"example":true}`)
|
||||||
f.store.SetSetting("plugin.data.example", `{"important":"content"}`)
|
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 {
|
if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 {
|
||||||
t.Fatal(status)
|
t.Fatal(status)
|
||||||
}
|
}
|
||||||
assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil))
|
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(pluginEnabledKey); err != nil || ok {
|
||||||
if _, ok, err := f.store.Setting(key); err != nil || ok {
|
t.Fatal("插件启用状态未重置")
|
||||||
t.Fatalf("未重置 %s", key)
|
|
||||||
}
|
}
|
||||||
|
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"}` {
|
if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` {
|
||||||
t.Fatal("插件数据丢失")
|
t.Fatal("插件数据丢失")
|
||||||
|
|||||||
@@ -481,7 +481,7 @@
|
|||||||
"bearer": []
|
"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": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -490,6 +490,16 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"pinnedTabs": {
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Ordered vault-relative Markdown paths. Empty means no pins; null removes the setting."
|
||||||
|
},
|
||||||
"trashRetentionDays": {
|
"trashRetentionDays": {
|
||||||
"type": [
|
"type": [
|
||||||
"integer",
|
"integer",
|
||||||
@@ -3789,6 +3799,7 @@
|
|||||||
"/maintenance/settings/reset": {
|
"/maintenance/settings/reset": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Reset confirmed preferences while retaining plugin storage",
|
"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": [
|
"tags": [
|
||||||
"Maintenance"
|
"Maintenance"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -56,8 +56,19 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
func (s *Store) ResetPreferences() error {
|
func (s *Store) ResetPreferences() error {
|
||||||
_, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`)
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
return err
|
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 }
|
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
|
||||||
|
|
||||||
|
|||||||
@@ -48,3 +48,31 @@ func TestOpenExistingUsesNativeEscapedPathWithoutCreating(t *testing.T) {
|
|||||||
t.Fatalf("missing database was created: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { t as translate } from '@@/i18n/runtime'
|
|||||||
import { commandTitle } from '@@/utils/commands'
|
import { commandTitle } from '@@/utils/commands'
|
||||||
import { noteTitle } from '@@/utils/paths'
|
import { noteTitle } from '@@/utils/paths'
|
||||||
import { nextTick, ref } from 'vue'
|
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.
|
// 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'
|
const TAB_TYPE = 'application/x-rawmark-tab'
|
||||||
@@ -113,7 +113,7 @@ function closeRight(path: string) {
|
|||||||
<template v-if="menu">
|
<template v-if="menu">
|
||||||
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
|
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
|
||||||
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }">
|
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }">
|
||||||
<button @click="menu.tab.pinned = !menu.tab.pinned; menu = null">
|
<button @click="toggleTabPin(menu.tab.path); menu = null">
|
||||||
{{ menu.tab.pinned ? translate('workspace.unpin_20590a') : translate('workspace.pin_d4b3f4') }}
|
{{ menu.tab.pinned ? translate('workspace.unpin_20590a') : translate('workspace.pin_d4b3f4') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="closeTab(menu.tab.path); menu = null">
|
<button @click="closeTab(menu.tab.path); menu = null">
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { ApiError } from '@/http/axios'
|
import { ApiError } from '@/http/axios'
|
||||||
|
|
||||||
import { pinia } from '@/pinia'
|
import { pinia } from '@/pinia'
|
||||||
|
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||||
import { useUserStore } from '@/pinia/stores/user'
|
import { useUserStore } from '@/pinia/stores/user'
|
||||||
import { apiSuccess } from '../../../tests/api-response'
|
import { apiSuccess } from '../../../tests/api-response'
|
||||||
import { pluginEvents } from '../../common/plugins/registry'
|
import { pluginEvents } from '../../common/plugins/registry'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { insertImageFiles } from './editor/paste'
|
import { insertImageFiles } from './editor/paste'
|
||||||
import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
|
import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, toggleTabPin, ws } from './workspace'
|
||||||
|
|
||||||
vi.mock('@@/apis/events', () => ({ connectEventsApi: vi.fn(() => () => {}) }))
|
vi.mock('@@/apis/events', () => ({ connectEventsApi: vi.fn(() => () => {}) }))
|
||||||
|
|
||||||
@@ -116,6 +117,249 @@ describe('事件同步', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('固定标签跨设备同步', () => {
|
||||||
|
const { settings, flushSettings } = useSettingsStore(pinia)
|
||||||
|
let serverPins: string[] | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serverPins = undefined
|
||||||
|
settings.pinnedTabs = undefined
|
||||||
|
localStorage.removeItem('rawmark.tabs')
|
||||||
|
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: ['a.md', 'b.md', 'from/note.md'].map(path => ({ path, dir: false, size: 0, mtime: 0 })) }))
|
||||||
|
vi.spyOn(settingsApi, 'getSettingsApi').mockImplementation(async () => apiSuccess(serverPins === undefined ? {} : { pinnedTabs: [...serverPins] }))
|
||||||
|
vi.spyOn(settingsApi, 'patchSettingsApi').mockImplementation(async (patch) => {
|
||||||
|
serverPins = patch.pinnedTabs as string[]
|
||||||
|
return apiSuccess({ pinnedTabs: serverPins })
|
||||||
|
})
|
||||||
|
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
|
||||||
|
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
|
||||||
|
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '服务器正文', rev: 'r1' })
|
||||||
|
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||||
|
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
stopWorkspace()
|
||||||
|
await flushSettings().catch(() => {})
|
||||||
|
await flushPromises()
|
||||||
|
settings.pinnedTabs = undefined
|
||||||
|
localStorage.removeItem('rawmark.tabs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('固定后保存服务器,另一设备没有本地标签也能恢复', async () => {
|
||||||
|
await startWorkspace()
|
||||||
|
await openNote('a.md')
|
||||||
|
await toggleTabPin('a.md')
|
||||||
|
expect(serverPins).toEqual(['a.md'])
|
||||||
|
expect(ws.tabs[0].pinned).toBe(true)
|
||||||
|
stopWorkspace()
|
||||||
|
await flushPromises()
|
||||||
|
localStorage.removeItem('rawmark.tabs')
|
||||||
|
settings.pinnedTabs = undefined
|
||||||
|
await startWorkspace()
|
||||||
|
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['a.md', true]])
|
||||||
|
expect(editorStateOf('a.md')?.doc.toString()).toBe('服务器正文')
|
||||||
|
expect(ws.active).toBe('a.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只在服务器尚无固定列表时迁移本地固定标签', async () => {
|
||||||
|
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
|
||||||
|
await startWorkspace()
|
||||||
|
expect(serverPins).toEqual(['a.md'])
|
||||||
|
expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1)
|
||||||
|
await flushPromises()
|
||||||
|
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!)).not.toHaveProperty('pinned')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('服务器已取消所有固定时,旧设备缓存不能重新固定', async () => {
|
||||||
|
serverPins = []
|
||||||
|
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
|
||||||
|
await startWorkspace()
|
||||||
|
expect(ws.tabs[0].pinned).toBe(false)
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('加载服务器失败时保留本地固定状态,不上传未确认的列表', async () => {
|
||||||
|
vi.mocked(settingsApi.getSettingsApi).mockRejectedValueOnce(new ApiError(503, '设置暂不可用'))
|
||||||
|
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
|
||||||
|
await startWorkspace()
|
||||||
|
expect(ws.tabs[0].pinned).toBe(true)
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!).pinned).toEqual(['a.md'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('其他设备固定和取消固定,不切换视图或关闭本机未保存标签', async () => {
|
||||||
|
serverPins = ['a.md']
|
||||||
|
await startWorkspace()
|
||||||
|
const tab = ws.tabs[0]
|
||||||
|
tab.dirty = true
|
||||||
|
const state = editorStateOf('a.md')
|
||||||
|
ws.view = 'graph'
|
||||||
|
ws.images = ''
|
||||||
|
ws.sidebarOpen = true
|
||||||
|
serverPins = ['b.md']
|
||||||
|
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'settings' }])
|
||||||
|
await vi.waitFor(() => expect(ws.tabs.find(t => t.path === 'b.md')?.status).toBe('ready'))
|
||||||
|
expect(ws.active).toBe('a.md')
|
||||||
|
expect(ws.view).toBe('graph')
|
||||||
|
expect(ws.images).toBe('')
|
||||||
|
expect(ws.sidebarOpen).toBe(true)
|
||||||
|
expect(tab.pinned).toBe(false)
|
||||||
|
expect(tab.dirty).toBe(true)
|
||||||
|
expect(editorStateOf('a.md')).toBe(state)
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
tab.dirty = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('断线重连补回错过的固定设置', async () => {
|
||||||
|
serverPins = []
|
||||||
|
await startWorkspace()
|
||||||
|
serverPins = ['b.md']
|
||||||
|
vi.mocked(connectEventsApi).mock.lastCall![0].onResync()
|
||||||
|
await vi.waitFor(() => expect(ws.tabs.find(tab => tab.path === 'b.md')?.pinned).toBe(true))
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存失败时提示并恢复服务器状态', async () => {
|
||||||
|
serverPins = []
|
||||||
|
await startWorkspace()
|
||||||
|
await openNote('a.md')
|
||||||
|
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(503, '固定保存失败'))
|
||||||
|
await toggleTabPin('a.md')
|
||||||
|
expect(ws.tabs[0].pinned).toBe(false)
|
||||||
|
expect(settings.pinnedTabs).toEqual([])
|
||||||
|
expect(toasts.some(toast => toast.text.includes('固定保存失败'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('快速固定和取消固定按操作顺序保存', async () => {
|
||||||
|
serverPins = []
|
||||||
|
await startWorkspace()
|
||||||
|
await openNote('a.md')
|
||||||
|
await Promise.all([toggleTabPin('a.md'), toggleTabPin('a.md'), toggleTabPin('a.md')])
|
||||||
|
expect(vi.mocked(settingsApi.patchSettingsApi).mock.calls.map(([patch]) => patch.pinnedTabs)).toEqual([['a.md'], [], ['a.md']])
|
||||||
|
expect(serverPins).toEqual(['a.md'])
|
||||||
|
expect(ws.tabs[0].pinned).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('主动关闭固定标签也从服务器固定列表中移除', async () => {
|
||||||
|
serverPins = ['a.md', 'b.md']
|
||||||
|
await startWorkspace()
|
||||||
|
expect(await closeTab('a.md')).toBe(true)
|
||||||
|
expect(serverPins).toEqual(['b.md'])
|
||||||
|
expect(ws.tabs.some(tab => tab.path === 'a.md')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消固定保存失败时保留标签,不丢失固定状态', async () => {
|
||||||
|
serverPins = ['a.md']
|
||||||
|
await startWorkspace()
|
||||||
|
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(503, '保存失败'))
|
||||||
|
expect(await closeTab('a.md')).toBe(false)
|
||||||
|
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['a.md', true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('关闭时草稿保存失败,保留标签和服务器固定记录', async () => {
|
||||||
|
serverPins = ['a.md']
|
||||||
|
await startWorkspace()
|
||||||
|
const tab = ws.tabs[0]
|
||||||
|
Object.assign(tab, { status: 'conflict', dirty: true })
|
||||||
|
const put = vi.spyOn(drafts, 'put').mockRejectedValueOnce(new Error('草稿存储不可用'))
|
||||||
|
expect(await closeTab('a.md')).toBe(false)
|
||||||
|
expect(ws.tabs).toContain(tab)
|
||||||
|
expect(tab.pinned).toBe(true)
|
||||||
|
expect(serverPins).toEqual(['a.md'])
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
put.mockResolvedValue('a.md')
|
||||||
|
tab.dirty = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消固定请求期间又输入,保留新内容并恢复固定', async () => {
|
||||||
|
serverPins = ['a.md']
|
||||||
|
await startWorkspace()
|
||||||
|
const v = await edit('a.md')
|
||||||
|
const original = v.state.doc.toString()
|
||||||
|
vi.spyOn(drafts, 'put').mockResolvedValue('a.md')
|
||||||
|
let finish!: () => void
|
||||||
|
vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(async (patch) => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
serverPins = patch.pinnedTabs as string[]
|
||||||
|
return apiSuccess({ pinnedTabs: serverPins })
|
||||||
|
})
|
||||||
|
const closing = closeTab('a.md')
|
||||||
|
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||||
|
v.dispatch({ changes: { from: 0, insert: '新增输入' } })
|
||||||
|
finish()
|
||||||
|
expect(await closing).toBe(false)
|
||||||
|
expect(serverPins).toEqual(['a.md'])
|
||||||
|
expect(ws.tabs[0].pinned).toBe(true)
|
||||||
|
expect(ws.tabs[0].dirty).toBe(true)
|
||||||
|
expect(v.state.doc.toString()).toBe(`新增输入${original}`)
|
||||||
|
ws.tabs[0].dirty = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重置设置后同步取消固定,重新登录不会迁回旧记录', async () => {
|
||||||
|
serverPins = ['a.md']
|
||||||
|
await startWorkspace()
|
||||||
|
const tab = ws.tabs[0]
|
||||||
|
tab.dirty = true
|
||||||
|
const state = editorStateOf('a.md')
|
||||||
|
serverPins = []
|
||||||
|
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'settings' }])
|
||||||
|
await vi.waitFor(() => expect(tab.pinned).toBe(false))
|
||||||
|
expect(ws.tabs).toContain(tab)
|
||||||
|
expect(tab.dirty).toBe(true)
|
||||||
|
expect(editorStateOf('a.md')).toBe(state)
|
||||||
|
await flushPromises()
|
||||||
|
expect(JSON.parse(localStorage.getItem('rawmark.tabs')!)).not.toHaveProperty('pinned')
|
||||||
|
tab.dirty = false
|
||||||
|
stopWorkspace()
|
||||||
|
await flushPromises()
|
||||||
|
// Another device may still hold a legacy cache; the reset must remain authoritative.
|
||||||
|
localStorage.setItem('rawmark.tabs', JSON.stringify({ paths: ['a.md'], active: 'a.md', pinned: ['a.md'] }))
|
||||||
|
await startWorkspace()
|
||||||
|
expect(ws.tabs[0].pinned).toBe(false)
|
||||||
|
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在本机重命名固定笔记后,新设备仍能按新路径恢复', async () => {
|
||||||
|
serverPins = ['from/note.md']
|
||||||
|
await startWorkspace()
|
||||||
|
vi.spyOn(filesApi, 'moveFileApi').mockResolvedValue(apiSuccess({ ok: true }))
|
||||||
|
vi.mocked(treeApi.getTreeApi).mockResolvedValue(apiSuccess({ entries: [{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }] }))
|
||||||
|
expect(await move('from', 'to')).toEqual({ ok: true })
|
||||||
|
expect(serverPins).toEqual(['to/note.md'])
|
||||||
|
stopWorkspace()
|
||||||
|
await flushPromises()
|
||||||
|
localStorage.removeItem('rawmark.tabs')
|
||||||
|
await startWorkspace()
|
||||||
|
expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['to/note.md', true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目录重命名后,服务器固定列表跟随新路径', async () => {
|
||||||
|
serverPins = ['from/note.md']
|
||||||
|
await startWorkspace()
|
||||||
|
vi.mocked(treeApi.getTreeApi).mockResolvedValue(apiSuccess({ entries: [{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }] }))
|
||||||
|
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
|
||||||
|
await vi.waitFor(() => expect(serverPins).toEqual(['to/note.md']))
|
||||||
|
await vi.waitFor(() => expect(ws.tabs.map(tab => [tab.path, tab.pinned])).toEqual([['to/note.md', true]]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('旧工作区未完成的标签读取不覆盖新工作区同名笔记', async () => {
|
||||||
|
let finish!: (result: { content: string, rev: string }) => void
|
||||||
|
vi.mocked(noteFiles.readNoteFile).mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
}))
|
||||||
|
const opening = openNote('a.md', { background: true })
|
||||||
|
stopWorkspace()
|
||||||
|
await openNote('a.md')
|
||||||
|
finish({ content: '旧工作区正文', rev: 'old' })
|
||||||
|
await opening
|
||||||
|
expect(editorStateOf('a.md')?.doc.toString()).toBe('服务器正文')
|
||||||
|
expect(ws.tabs[0].rev).toBe('r1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function edit(path: string) {
|
async function edit(path: string) {
|
||||||
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' })
|
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' })
|
||||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CanceledError, isCancel } from 'axios'
|
|||||||
import { nextTick, reactive, watch } from 'vue'
|
import { nextTick, reactive, watch } from 'vue'
|
||||||
import { ApiError } from '@/http/axios'
|
import { ApiError } from '@/http/axios'
|
||||||
import { pinia } from '@/pinia'
|
import { pinia } from '@/pinia'
|
||||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
|
||||||
import { useUserStore } from '@/pinia/stores/user'
|
import { useUserStore } from '@/pinia/stores/user'
|
||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor'
|
import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor'
|
||||||
@@ -38,7 +38,7 @@ import { editorLocale, localeCompartment } from './editor/locale'
|
|||||||
import { editorCommands } from './editor/shortcuts'
|
import { editorCommands } from './editor/shortcuts'
|
||||||
import { refreshMetadata } from './metadata'
|
import { refreshMetadata } from './metadata'
|
||||||
|
|
||||||
const { loadSettings, settings } = useSettingsStore(pinia)
|
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
|
||||||
|
|
||||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||||
|
|
||||||
@@ -515,35 +515,90 @@ export function revealPending(v: EditorView, path: string): boolean {
|
|||||||
|
|
||||||
// ---- Tabs ----
|
// ---- Tabs ----
|
||||||
|
|
||||||
export async function openNote(path: string) {
|
export async function openNote(path: string, options?: { background?: boolean }) {
|
||||||
const version = user.getSessionVersion()
|
const version = user.getSessionVersion()
|
||||||
|
const epoch = workspaceEpoch
|
||||||
|
if (!options?.background) {
|
||||||
ws.view = 'notes'
|
ws.view = 'notes'
|
||||||
ws.images = null
|
ws.images = null
|
||||||
ws.sidebarOpen = false
|
ws.sidebarOpen = false
|
||||||
|
}
|
||||||
// New tree notes appear optimistically; wait for actual creation before reading them.
|
// New tree notes appear optimistically; wait for actual creation before reading them.
|
||||||
const pending = creating.get(path)
|
const pending = creating.get(path)
|
||||||
if (pending && !(await pending)) return
|
if (pending && !(await pending)) return
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
if (tabOf(path)) {
|
if (tabOf(path)) {
|
||||||
ws.active = path
|
if (!options?.background) ws.active = path
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const tab: Tab = { path, rev: '', status: 'loading', dirty: false }
|
const tab = reactive<Tab>({ path, pinned: settings.pinnedTabs?.includes(path) ?? false, rev: '', status: 'loading', dirty: false })
|
||||||
ws.tabs.push(tab)
|
ws.tabs.push(tab)
|
||||||
ws.active = path
|
if (!options?.background) ws.active = path
|
||||||
try {
|
try {
|
||||||
const { content, rev } = await readNoteFile(path)
|
const { content, rev } = await readNoteFile(path)
|
||||||
|
user.assertSession(version)
|
||||||
|
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
|
||||||
editorStates.set(path, buildState(path, content))
|
editorStates.set(path, buildState(path, content))
|
||||||
const t = tabOf(path)
|
const t = tabOf(path)
|
||||||
if (t) Object.assign(t, { rev, status: 'ready' })
|
if (t) Object.assign(t, { rev, status: 'ready' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isCancel(err)) return
|
if (isCancel(err)) return
|
||||||
|
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
|
||||||
const t = tabOf(path)
|
const t = tabOf(path)
|
||||||
if (t) t.status = 'error'
|
if (t) t.status = 'error'
|
||||||
toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) }))
|
toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncPinnedTabs() {
|
||||||
|
if (settings.pinnedTabs === undefined) return
|
||||||
|
const pinned = new Set(settings.pinnedTabs)
|
||||||
|
for (const tab of ws.tabs) tab.pinned = pinned.has(tab.path)
|
||||||
|
// Remote unpins never close tabs or discard local drafts, and remote pins never steal focus.
|
||||||
|
const notes = new Set(ws.entries.filter(entry => !entry.dir && entry.path.endsWith(NOTE_EXT)).map(entry => entry.path))
|
||||||
|
await Promise.all([...pinned].filter(path => notes.has(path) && !tabOf(path)).map(path => openNote(path, { background: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSettings() {
|
||||||
|
await loadSettings()
|
||||||
|
await syncPinnedTabs()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePinnedTabs(paths: string[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const saving = updateSettings({ pinnedTabs: paths })
|
||||||
|
for (const tab of ws.tabs) tab.pinned = paths.includes(tab.path)
|
||||||
|
await saving
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (isCancel(err)) return false
|
||||||
|
if (err instanceof SettingsAppearanceError) {
|
||||||
|
toast(err.message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
toast(translate('common.could_not_save_settings_1cdd7f', { p0: err instanceof Error ? err.message : errorText(err) }))
|
||||||
|
try {
|
||||||
|
await syncSettings()
|
||||||
|
} catch (reload) {
|
||||||
|
if (!isCancel(reload)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(reload) }))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTabPin(path: string) {
|
||||||
|
if (!tabOf(path)) return
|
||||||
|
const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
|
||||||
|
await savePinnedTabs(pinned.includes(path) ? pinned.filter(p => p !== path) : [...pinned, path])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remapPinnedTabs(from: string, to: string) {
|
||||||
|
const pinned = settings.pinnedTabs
|
||||||
|
if (pinned?.some(path => remapPath(path, from, to) !== null)) {
|
||||||
|
await savePinnedTabs(pinned.map(path => remapPath(path, from, to) ?? path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeTab(path: string): Promise<boolean> {
|
export async function closeTab(path: string): Promise<boolean> {
|
||||||
const version = user.getSessionVersion()
|
const version = user.getSessionVersion()
|
||||||
const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused)
|
const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused)
|
||||||
@@ -575,6 +630,19 @@ export async function closeTab(path: string): Promise<boolean> {
|
|||||||
if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false
|
if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false
|
||||||
toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab'))
|
toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab'))
|
||||||
}
|
}
|
||||||
|
if (tab.pinned) {
|
||||||
|
const seq = slot(path).seq
|
||||||
|
const pinned = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
|
||||||
|
if (!(await savePinnedTabs(pinned.filter(p => p !== path)))) return false
|
||||||
|
user.assertSession(version)
|
||||||
|
if (!ws.tabs.includes(tab)) return true
|
||||||
|
// Saving the pin is asynchronous; retain a tab changed by new input or another operation while it was pending.
|
||||||
|
if (tab.path !== path || slot(path).seq !== seq || busy()) {
|
||||||
|
const current = settings.pinnedTabs ?? ws.tabs.filter(tab => tab.pinned).map(tab => tab.path)
|
||||||
|
if (!current.includes(tab.path)) await savePinnedTabs([...current, tab.path])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const index = ws.tabs.indexOf(tab)
|
const index = ws.tabs.indexOf(tab)
|
||||||
if (index < 0) return true
|
if (index < 0) return true
|
||||||
ws.tabs.splice(index, 1)
|
ws.tabs.splice(index, 1)
|
||||||
@@ -875,6 +943,8 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
|
|||||||
} finally {
|
} finally {
|
||||||
paused.forEach(s => s.paused--)
|
paused.forEach(s => s.paused--)
|
||||||
}
|
}
|
||||||
|
await remapPinnedTabs(from, to)
|
||||||
|
user.assertSession(version)
|
||||||
// The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization.
|
// The server rewrites image links when renaming notes, changing their revisions and requiring resynchronization.
|
||||||
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
|
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
|
||||||
const tab = tabOf(p)
|
const tab = tabOf(p)
|
||||||
@@ -994,7 +1064,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
|||||||
if (ev.type === 'indexed') {
|
if (ev.type === 'indexed') {
|
||||||
void refreshMetadata()
|
void refreshMetadata()
|
||||||
} else if (ev.type === 'settings') {
|
} else if (ev.type === 'settings') {
|
||||||
void loadSettings().catch((err) => {
|
await syncSettings().catch((err) => {
|
||||||
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
|
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
|
||||||
})
|
})
|
||||||
await refreshPlugins()
|
await refreshPlugins()
|
||||||
@@ -1024,6 +1094,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
|||||||
if (epoch !== workspaceEpoch) return
|
if (epoch !== workspaceEpoch) return
|
||||||
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
|
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
|
||||||
remapRecent(ev.from!, ev.path!)
|
remapRecent(ev.from!, ev.path!)
|
||||||
|
await remapPinnedTabs(ev.from!, ev.path!)
|
||||||
} finally {
|
} finally {
|
||||||
paused.forEach(s => s.paused--)
|
paused.forEach(s => s.paused--)
|
||||||
}
|
}
|
||||||
@@ -1049,6 +1120,11 @@ async function resync() {
|
|||||||
await refreshTree()
|
await refreshTree()
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
if (epoch !== workspaceEpoch) return
|
if (epoch !== workspaceEpoch) return
|
||||||
|
await syncSettings().catch((err) => {
|
||||||
|
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
|
||||||
|
})
|
||||||
|
user.assertSession(version)
|
||||||
|
if (epoch !== workspaceEpoch) return
|
||||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
if (epoch !== workspaceEpoch) return
|
if (epoch !== workspaceEpoch) return
|
||||||
@@ -1060,6 +1136,10 @@ async function resync() {
|
|||||||
|
|
||||||
const TABS_KEY = 'rawmark.tabs'
|
const TABS_KEY = 'rawmark.tabs'
|
||||||
|
|
||||||
|
function localTabs() {
|
||||||
|
return { paths: ws.tabs.map(t => t.path), active: ws.active, ...(settings.pinnedTabs === undefined ? { pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
// Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution.
|
// Restore unsaved drafts: continue saving if the server revision is unchanged; otherwise enter conflict state for user resolution.
|
||||||
async function recoverDrafts() {
|
async function recoverDrafts() {
|
||||||
const version = user.getSessionVersion()
|
const version = user.getSessionVersion()
|
||||||
@@ -1101,15 +1181,21 @@ export async function startWorkspace() {
|
|||||||
await prepareResources()
|
await prepareResources()
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
// Settings-read failures do not prevent use; retain locally cached values.
|
// Settings-read failures do not prevent use; retain locally cached values.
|
||||||
const settingsLoaded = loadSettings().catch((err) => {
|
const settingsLoaded = loadSettings().then(() => true).catch((err) => {
|
||||||
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
|
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
await refreshTree()
|
await refreshTree()
|
||||||
await settingsLoaded
|
const confirmedSettings = await settingsLoaded
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[], active: string | null, pinned?: string[] }
|
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[], active: string | null, pinned?: string[] }
|
||||||
const exists = new Set(ws.entries.map(e => e.path))
|
const exists = new Set(ws.entries.map(e => e.path))
|
||||||
await Promise.all(saved.paths.filter(p => exists.has(p)).map(openNote))
|
await Promise.all(saved.paths.filter(p => exists.has(p)).map(path => openNote(path)))
|
||||||
|
if (settings.pinnedTabs === undefined) {
|
||||||
|
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
|
||||||
|
if (confirmedSettings && saved.pinned?.length) await savePinnedTabs(saved.pinned.filter(path => exists.has(path)))
|
||||||
|
}
|
||||||
|
if (settings.pinnedTabs !== undefined) await syncPinnedTabs()
|
||||||
try {
|
try {
|
||||||
await recoverDrafts()
|
await recoverDrafts()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1117,9 +1203,9 @@ export async function startWorkspace() {
|
|||||||
toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) }))
|
toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) }))
|
||||||
}
|
}
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
|
|
||||||
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
|
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
|
||||||
workspaceRunning = true
|
workspaceRunning = true
|
||||||
|
localStorage.setItem(TABS_KEY, JSON.stringify(localTabs()))
|
||||||
let eventQueue = Promise.resolve()
|
let eventQueue = Promise.resolve()
|
||||||
let stopped = false
|
let stopped = false
|
||||||
const enqueue = (work: () => Promise<void>) => {
|
const enqueue = (work: () => Promise<void>) => {
|
||||||
@@ -1151,12 +1237,10 @@ export async function startWorkspace() {
|
|||||||
if (listenersInstalled) return
|
if (listenersInstalled) return
|
||||||
listenersInstalled = true
|
listenersInstalled = true
|
||||||
watch(
|
watch(
|
||||||
() => ({ paths: ws.tabs.map(t => t.path), active: ws.active, pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) }),
|
localTabs,
|
||||||
(tabs) => {
|
(tabs) => {
|
||||||
if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs))
|
if (workspaceRunning) localStorage.setItem(TABS_KEY, JSON.stringify(tabs))
|
||||||
},
|
},
|
||||||
// Record tabs opened during startup immediately, including while waiting for tree refresh.
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => ws.active,
|
() => ws.active,
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ async function search(selector: string, query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('设置查找与重置', () => {
|
describe('设置查找与重置', () => {
|
||||||
|
it('服务器固定标签去重并过滤无效数据,区分首次迁移与明确清空', async () => {
|
||||||
|
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ pinnedTabs: ['a.md', 1, '', 'a.md', '目录/b.md'] }))
|
||||||
|
await loadSettings()
|
||||||
|
expect(settings.pinnedTabs).toEqual(['a.md', '目录/b.md'])
|
||||||
|
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ pinnedTabs: [] }))
|
||||||
|
await loadSettings()
|
||||||
|
expect(settings.pinnedTabs).toEqual([])
|
||||||
|
await loadSettings()
|
||||||
|
expect(settings.pinnedTabs).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('分类按约定排序,令牌归入账户与安全', async () => {
|
it('分类按约定排序,令牌归入账户与安全', async () => {
|
||||||
ws.settings = 'account'
|
ws.settings = 'account'
|
||||||
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
|
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const CONTENT_WIDTHS = [
|
|||||||
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
||||||
|
|
||||||
export const DEFAULTS = { locale: DEFAULT_LOCALE, trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
export const DEFAULTS = { locale: DEFAULT_LOCALE, trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
||||||
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
|
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]>, pinnedTabs?: string[] }
|
||||||
|
|
||||||
const CACHE_KEY = 'rawmark.settings'
|
const CACHE_KEY = 'rawmark.settings'
|
||||||
|
|
||||||
@@ -55,6 +55,8 @@ function sanitize(raw: Record<string, unknown>): Settings {
|
|||||||
return {
|
return {
|
||||||
locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE,
|
locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE,
|
||||||
hiddenFonts,
|
hiddenFonts,
|
||||||
|
// Absence permits a one-time migration from browser-only pinned tabs; an empty array is authoritative.
|
||||||
|
pinnedTabs: raw.pinnedTabs === undefined ? undefined : [...new Set(Array.isArray(raw.pinnedTabs) ? raw.pinnedTabs.filter((path): path is string => typeof path === 'string' && path.length > 0) : [])],
|
||||||
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
|
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
|
||||||
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
|
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
|
||||||
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
||||||
|
|||||||
Reference in new issue
Block a user