feat: synchronize pinned tabs across devices
Development CI / check-and-package (push) Successful in 4m18s
GitHub Git mirror / mirror (push) Successful in 28s
Development CI / image (push) Successful in 21s
Development CI / deploy-development (push) Successful in 15s

This commit is contained in:
eezd committed 2026-11-04 18:20:16 +08:00
1 parent 403824eccb
commit 5514a1e465
12 files changed
+454 -29

No files matched your search

@@ -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) {
<template v-if="menu">
<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` }">
<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') }}
</button>
<button @click="closeTab(menu.tab.path); menu = null">
+245 -1
View File
@@ -18,12 +18,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/http/axios'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../tests/api-response'
import { pluginEvents } from '../../common/plugins/registry'
import { drafts } from './drafts'
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(() => () => {}) }))
@@ -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) {
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
+101 -17
View File
@@ -29,7 +29,7 @@ import { CanceledError, isCancel } from 'axios'
import { nextTick, reactive, watch } from 'vue'
import { ApiError } from '@/http/axios'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import { drafts } from './drafts'
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 { refreshMetadata } from './metadata'
const { loadSettings, settings } = useSettingsStore(pinia)
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
@@ -515,35 +515,90 @@ export function revealPending(v: EditorView, path: string): boolean {
// ---- Tabs ----
export async function openNote(path: string) {
export async function openNote(path: string, options?: { background?: boolean }) {
const version = user.getSessionVersion()
ws.view = 'notes'
ws.images = null
ws.sidebarOpen = false
const epoch = workspaceEpoch
if (!options?.background) {
ws.view = 'notes'
ws.images = null
ws.sidebarOpen = false
}
// New tree notes appear optimistically; wait for actual creation before reading them.
const pending = creating.get(path)
if (pending && !(await pending)) return
user.assertSession(version)
if (tabOf(path)) {
ws.active = path
if (!options?.background) ws.active = path
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.active = path
if (!options?.background) ws.active = path
try {
const { content, rev } = await readNoteFile(path)
user.assertSession(version)
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
editorStates.set(path, buildState(path, content))
const t = tabOf(path)
if (t) Object.assign(t, { rev, status: 'ready' })
} catch (err) {
if (isCancel(err)) return
if (epoch !== workspaceEpoch || tabOf(path) !== tab) return
const t = tabOf(path)
if (t) t.status = 'error'
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> {
const version = user.getSessionVersion()
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
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)
if (index < 0) return true
ws.tabs.splice(index, 1)
@@ -875,6 +943,8 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
} finally {
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.
await Promise.all(ws.tabs.map(t => t.path).map(async (p) => {
const tab = tabOf(p)
@@ -994,7 +1064,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (ev.type === 'indexed') {
void refreshMetadata()
} 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) }))
})
await refreshPlugins()
@@ -1024,6 +1094,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (epoch !== workspaceEpoch) return
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
await remapPinnedTabs(ev.from!, ev.path!)
} finally {
paused.forEach(s => s.paused--)
}
@@ -1049,6 +1120,11 @@ async function resync() {
await refreshTree()
user.assertSession(version)
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))
user.assertSession(version)
if (epoch !== workspaceEpoch) return
@@ -1060,6 +1136,10 @@ async function resync() {
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.
async function recoverDrafts() {
const version = user.getSessionVersion()
@@ -1101,15 +1181,21 @@ export async function startWorkspace() {
await prepareResources()
user.assertSession(version)
// 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) }))
return false
})
await refreshTree()
await settingsLoaded
const confirmedSettings = await settingsLoaded
user.assertSession(version)
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))
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 {
await recoverDrafts()
} catch (err) {
@@ -1117,9 +1203,9 @@ export async function startWorkspace() {
toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) }))
}
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)
workspaceRunning = true
localStorage.setItem(TABS_KEY, JSON.stringify(localTabs()))
let eventQueue = Promise.resolve()
let stopped = false
const enqueue = (work: () => Promise<void>) => {
@@ -1151,12 +1237,10 @@ export async function startWorkspace() {
if (listenersInstalled) return
listenersInstalled = true
watch(
() => ({ paths: ws.tabs.map(t => t.path), active: ws.active, pinned: ws.tabs.filter(t => t.pinned).map(t => t.path) }),
localTabs,
(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(
() => ws.active,
+11
View File
@@ -78,6 +78,17 @@ async function search(selector: string, query: string) {
}
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 () => {
ws.settings = 'account'
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
+3 -1
View File
@@ -36,7 +36,7 @@ export const CONTENT_WIDTHS = [
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 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'
@@ -55,6 +55,8 @@ function sanitize(raw: Record<string, unknown>): Settings {
return {
locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE,
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,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,