fix(workspace): background vector indexing and correct diagram previews

This commit is contained in:
2026-09-06 02:57:56 +08:00
parent 9e0715f9db
commit a5c44c4ac0
21 changed files with 561 additions and 75 deletions
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
@@ -10,6 +10,7 @@ import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
import { getIndexStatus } from '@/services/indexService'
import { navigateToCitation } from '@/composables/useCitationNavigation'
defineProps<{
@@ -23,7 +24,14 @@ const settingsStore = useSettingsStore()
const route = useRoute()
const router = useRouter()
onMounted(() => { void settingsStore.loadDiagnostics() })
let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
async function pollIndex() {
try { settingsStore.indexStatus = await getIndexStatus() } catch { /* retain last status; retry */ }
if (!disposed) statusTimer = setTimeout(pollIndex, 5000)
}
onMounted(() => { void settingsStore.loadDiagnostics(); void pollIndex() })
onUnmounted(() => { disposed = true; clearTimeout(statusTimer) })
watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true })
watch(() => settingsStore.editorLineWidth, (width) => {
document.documentElement.style.setProperty('--editor-line-width', `${width}ch`)
@@ -82,3 +82,59 @@ it('zooms directly in the viewer with bounded speed even for a large wheel delta
expect(Number(dialog.querySelector('output')!.textContent!.replace('%', ''))).toBeLessThanOrEqual(105)
wrapper.unmount()
})
it('starts wheel zoom from the fitted width instead of the intrinsic SVG width', async () => {
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 4000 2000"></svg></div>' }, attachTo: document.body })
const svg = wrapper.get('svg').element as SVGSVGElement
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ width: 400, height: 200, left: 0, top: 0 } as DOMRect)
await wrapper.get('svg').trigger('mousedown', { button: 1 })
svg.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }))
expect(parseFloat(svg.style.width)).toBeGreaterThan(400)
expect(parseFloat(svg.style.width)).toBeLessThanOrEqual(420)
wrapper.unmount()
})
it('keeps the cursor point fixed by adjusting the scroll container during zoom', async () => {
let frame: FrameRequestCallback | undefined
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1 })
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid" style="overflow-x:auto;overflow-y:auto"><svg viewBox="0 0 400 200"></svg></div>' }, attachTo: document.body })
try {
const container = wrapper.get('.markdown-mermaid').element as HTMLElement
const svg = wrapper.get('svg').element as SVGSVGElement
vi.spyOn(svg, 'getBoundingClientRect').mockImplementation(() => {
const width = parseFloat(svg.style.width) || 400
return { width, height: width / 2, left: -container.scrollLeft, top: -container.scrollTop } as DOMRect
})
await wrapper.get('svg').trigger('mousedown', { button: 1, clientX: 100, clientY: 50 })
svg.dispatchEvent(new WheelEvent('wheel', { clientX: 100, clientY: 50, deltaY: -100, bubbles: true, cancelable: true }))
frame?.(performance.now())
const rect = svg.getBoundingClientRect()
expect(rect.left + rect.width * .25).toBeCloseTo(100)
expect(rect.top + rect.height * .25).toBeCloseTo(50)
expect(container.scrollLeft).toBeGreaterThan(0)
} finally {
wrapper.unmount()
raf.mockRestore(); cancel.mockRestore()
}
})
it('fits the full chart on open and clears the previous viewport scroll', async () => {
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 2400 200"><text>Final task</text></svg><button data-diagram-action="view">View</button></div>' }, attachTo: document.body })
const dialog = document.querySelector('dialog')!
dialog.showModal = vi.fn()
const viewport = dialog.querySelector('.diagram-viewer-scroll') as HTMLElement
Object.defineProperty(viewport, 'clientWidth', { value: 1000 })
Object.defineProperty(viewport, 'clientHeight', { value: 600 })
viewport.scrollLeft = 900
viewport.scrollTop = 30
await wrapper.get('[data-diagram-action="view"]').trigger('click')
await flushPromises()
expect((dialog.querySelector('.diagram-viewer-image') as HTMLElement).style.width).toBe('1000px')
expect(viewport.scrollLeft).toBe(0)
expect(viewport.scrollTop).toBe(0)
expect(dialog.textContent).toContain('Final task')
wrapper.unmount()
})
@@ -13,6 +13,38 @@ let wheelTarget: HTMLElement | null = null
let anchor = { x: 0, y: 0 }
const wheelActive = ref(false)
let lastWheel = 0
const zoomBases = new WeakMap<HTMLElement, number>()
let anchorFrame = 0
let anchorUntil = 0
function stopAnchoring() { cancelAnimationFrame(anchorFrame); anchorFrame = 0 }
function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
stopAnchoring()
const rect = svg.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
const screenX = rect.left + x * rect.width
const screenY = rect.top + y * rect.height
const scrollers: HTMLElement[] = []
for (let node = svg.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (/(auto|scroll)/.test(`${style.overflowX} ${style.overflowY}`)) scrollers.push(node)
if (node === viewer.value) break
}
anchorUntil = performance.now() + 240
const follow = () => {
if (!svg.isConnected) return
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
// Re-measure after each scroll, letting the outer container take the remainder.
for (const node of scrollers) {
const current = svg.getBoundingClientRect()
node.scrollLeft += current.left + x * current.width - screenX
node.scrollTop += current.top + y * current.height - screenY
}
if (performance.now() < anchorUntil) anchorFrame = requestAnimationFrame(follow)
}
anchorFrame = requestAnimationFrame(follow)
}
function wheelFactor(event: WheelEvent) {
const now = performance.now()
const elapsed = lastWheel ? Math.min(100, Math.max(0, now - lastWheel)) : 80
@@ -22,9 +54,12 @@ function wheelFactor(event: WheelEvent) {
}
function viewerWheel(event: WheelEvent) {
event.preventDefault(); event.stopPropagation()
const svg = viewer.value?.querySelector<SVGSVGElement>('.diagram-viewer-image svg')
if (svg) anchorZoom(svg, event)
scale.value = Math.max(.2, Math.min(5, scale.value * wheelFactor(event)))
}
function disarm() {
stopAnchoring(); lastWheel = 0
wheelTarget?.removeAttribute('data-wheel-zoom')
wheelTarget = null; wheelActive.value = false
document.removeEventListener('mousemove', moved, true)
@@ -46,6 +81,8 @@ function arm(event: MouseEvent) {
function wheel(event: WheelEvent) {
if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return }
event.preventDefault(); event.stopPropagation()
const svg = wheelTarget.querySelector<SVGSVGElement>('svg')
if (svg) anchorZoom(svg, event)
const factor = wheelFactor(event)
if (wheelTarget.classList.contains('diagram-viewer-image')) scale.value = Math.max(.2, Math.min(5, scale.value * factor))
else zoom(wheelTarget, Math.max(.2, Math.min(5, Number(wheelTarget.dataset.diagramScale || 1) * factor)))
@@ -53,10 +90,12 @@ function wheel(event: WheelEvent) {
function zoom(diagram: HTMLElement, next: number) {
const svg = diagram.querySelector<SVGSVGElement>('svg')
if (!svg) return
if (!zoomBases.has(diagram)) zoomBases.set(diagram, svg.getBoundingClientRect().width || widthOf(svg))
diagram.dataset.diagramScale = String(next)
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px`
svg.style.width = next === 1 ? '' : `${zoomBases.get(diagram)! * next}px`
svg.style.maxWidth = next === 1 ? '' : 'none'
svg.style.height = 'auto'
if (next === 1) zoomBases.delete(diagram)
}
onBeforeUnmount(disarm)
function widthOf(svg: SVGSVGElement) {
@@ -72,8 +111,9 @@ async function interact(event: MouseEvent) {
event.stopPropagation()
const action = button.dataset.diagramAction
if (action === 'view') {
disarm()
opener = button
baseWidth.value = widthOf(svg)
const intrinsicWidth = widthOf(svg)
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
// integration point while still sanitizing the embedded HTML and handlers.
const copy = svg.cloneNode(true) as SVGSVGElement
@@ -88,6 +128,15 @@ async function interact(event: MouseEvent) {
scale.value = 1
await nextTick()
viewer.value?.showModal()
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
// Opening is independent of the inline preview's zoom and any previous modal scroll.
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
await nextTick()
if (viewport) { viewport.scrollLeft = 0; viewport.scrollTop = 0 }
return
}
const previous = Number(diagram.dataset.diagramScale || 1)
@@ -122,14 +171,15 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
.diagram-controls button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.diagram-viewer { margin: auto; width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; }
.diagram-viewer::backdrop { background: var(--color-background-overlay); }
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
.diagram-viewer-scroll { flex: 1; min-height: 0; overflow: auto; }
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
.diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
</style>
<style>
+2 -1
View File
@@ -40,7 +40,8 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? `${t('索引中', 'Indexing')} (${settingsStore.indexStatus.pending_jobs})` : t('索引错误', 'Index error')
if (s === 'idle' && settingsStore.indexStatus.vector_refresh_required) return t('全文可用 · 向量待重建', 'Full text ready · vectors need rebuilding')
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? t('后台计算索引', 'Indexing in background') : t('索引错误', 'Index error')
})
const aiCoreStatusText = computed(() => {
+2
View File
@@ -507,6 +507,7 @@ export interface ThemeConfig {
// ============ Index ============
export interface IndexStatus {
vector_refresh_required?: boolean
status: 'unknown' | 'idle' | 'indexing' | 'error'
pending_jobs: number
total_notes: number | null
@@ -800,6 +801,7 @@ export interface ApiTask {
}
export interface ApiIndexStatus {
vector_refresh_required?: boolean
total_notes: number
total_blocks: number
status: 'idle' | 'queued' | 'running' | 'failed'
@@ -408,6 +408,9 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
+22 -14
View File
@@ -7,6 +7,7 @@ import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
@@ -15,29 +16,34 @@ const settingsStore = useSettingsStore()
const isLoading = ref(false)
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
const openError = ref('')
onMounted(async () => {
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
onMounted(() => { void initializeVault() })
async function initializeVault() {
openError.value = ''
void settingsStore.loadDiagnostics().then(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}).catch(() => { aiCoreStatus.value = 'stopped' })
try { await workspaceStore.loadRecentVaults() }
catch (reason) { openError.value = reason instanceof Error ? reason.message : String(reason); return }
const lastVaultPath = localStorage.getItem('last-vault-path')
if (settingsStore.restoreLastVault && lastVaultPath) {
try {
await openVault(lastVaultPath)
return
} catch {
// Vault
localStorage.removeItem('last-vault-path')
}
await openVault(lastVaultPath)
}
{
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}
})
}
async function openVault(path: string) {
if (isLoading.value) return
isLoading.value = true
openError.value = ''
try {
await workspaceStore.openVault(path)
router.push('/workspace')
await router.push('/workspace')
void settingsStore.loadDiagnostics()
} catch (reason) {
openError.value = reason instanceof Error ? reason.message : String(reason)
if (reason instanceof ApiErrorClass && reason.code === 'WORKSPACE_PATH_MISMATCH') localStorage.removeItem('last-vault-path')
} finally {
isLoading.value = false
}
@@ -60,6 +66,8 @@ async function openFolderPicker() {
</div>
<div class="vault-card">
<div v-if="openError" class="error-banner" role="alert">{{ openError }} <button class="btn" @click="initializeVault" :disabled="isLoading">{{ t('重试', 'Retry') }}</button></div>
<p v-if="isLoading" role="status">{{ t('正在打开知识库', 'Opening knowledge base') }}</p>
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import apiClient from './apiClient'
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
it('aborts a stuck request with an actionable timeout', async () => {
vi.useFakeTimers()
let signal: AbortSignal | undefined
vi.stubGlobal('fetch', vi.fn((_url, options) => new Promise((_resolve, reject) => {
signal = options.signal
signal?.addEventListener('abort', () => reject(new Error('aborted')))
})))
const assertion = expect(apiClient.get('/api/workspace', { timeoutMs: 15000 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT' })
await vi.advanceTimersByTimeAsync(15000)
await assertion
expect(signal?.aborted).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('clears the deadline after success', async () => {
vi.useFakeTimers()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ok":true}', { headers: { 'Content-Type': 'application/json' } })))
expect(await apiClient.get('/health', { timeoutMs: 10000 })).toEqual({ ok: true })
expect(vi.getTimerCount()).toBe(0)
})
+13 -1
View File
@@ -9,6 +9,7 @@ export function resolveApiUrl(path: string): string {
}
interface RequestOptions extends RequestInit {
timeoutMs?: number
params?: Record<string, string | number | boolean | undefined>
token?: string
}
@@ -26,7 +27,13 @@ export class ApiErrorClass extends Error {
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, ...rest } = options
const { params, token, headers, timeoutMs, ...rest } = options
const controller = timeoutMs ? new AbortController() : null
let timedOut = false
const abort = () => controller?.abort()
if (rest.signal?.aborted) abort()
rest.signal?.addEventListener('abort', abort, { once: true })
const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined
let url = resolveApiUrl(path)
@@ -54,6 +61,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
const resp = await fetch(url, {
...rest,
signal: controller?.signal ?? rest.signal,
headers: reqHeaders,
})
@@ -78,8 +86,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
throw new ApiErrorClass(code, message, details)
} catch (e) {
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
if (e instanceof ApiErrorClass) throw e
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
} finally {
if (timer) clearTimeout(timer)
rest.signal?.removeEventListener('abort', abort)
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
function toIndexStatus(status: ApiIndexStatus): IndexStatus {
return {
vector_refresh_required: status.vector_refresh_required ?? false,
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
pending_jobs: status.pending_jobs,
total_notes: status.total_notes ?? null,
@@ -13,7 +14,7 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
}
export async function getIndexStatus(): Promise<IndexStatus> {
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status'))
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status', { timeoutMs: 10000 }))
}
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
+3 -3
View File
@@ -2,13 +2,13 @@ import apiClient from './apiClient'
import type { SystemStatus } from '@/contracts'
export function healthCheck(): Promise<{ status: string }> {
return apiClient.get('/health')
return apiClient.get('/health', { timeoutMs: 10000 })
}
export function getStatus(): Promise<SystemStatus> {
return apiClient.get('/api/status')
return apiClient.get('/api/status', { timeoutMs: 10000 })
}
export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
return apiClient.get('/api/permissions/policy')
return apiClient.get('/api/permissions/policy', { timeoutMs: 10000 })
}
+2 -2
View File
@@ -79,7 +79,7 @@ async function requireNoteId(filePath: string): Promise<string> {
}
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
return apiClient.get('/api/workspace')
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
}
export async function getRecentVaults(): Promise<VaultInfo[]> {
@@ -88,7 +88,7 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
}
export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items)
return {
vault_id: snapshot.workspace.vault_id,
+1
View File
@@ -67,6 +67,7 @@ export const useEditorStore = defineStore('editor', () => {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
} finally {
pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
}
})()
return pendingSave
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment happy-dom
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, expect, it, vi } from 'vitest'
import { useEditorStore } from './editor'
import * as workspace from '@/services/workspaceService'
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
it('saves text typed while the previous save is still pending', async () => {
vi.useFakeTimers()
setActivePinia(createPinia())
const store = useEditorStore()
store.currentFilePath = '/draft.md'
let release!: () => void
const write = vi.spyOn(workspace, 'saveFileContent').mockImplementationOnce(() => new Promise<void>(resolve => { release = resolve })).mockResolvedValue()
store.updateContent('first')
const saving = store.save()
store.updateContent('latest')
release()
await saving
expect(store.saveStatus).toBe('dirty')
await vi.advanceTimersByTimeAsync(1500)
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest')
expect(store.saveStatus).toBe('saved')
})