feat: synchronize pinned tabs across devices
This commit is contained in:
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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] }))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user