feat(desktop): 增加原生 Vault 写入与属性导入

This commit is contained in:
2026-09-07 16:52:30 +08:00
parent 14b3117379
commit 8eb09e4459
39 changed files with 6832 additions and 26 deletions
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => ({ enabled: false, invoke: vi.fn() }))
vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => native.enabled, invoke: native.invoke }))
import { contentHash, DesktopError, hostInvoke, nativeTree } from './desktop'
import * as workspace from '../workspaceService'
beforeEach(() => { native.enabled = false; native.invoke.mockReset() })
describe('原生 Workspace 适配', () => {
it('Web 不伪造原生能力', async () => {
await expect(hostInvoke('workspace_tree')).rejects.toThrow('DESKTOP_UNAVAILABLE')
expect(native.invoke).not.toHaveBeenCalled()
})
it('分层目录保留稳定文件身份', () => {
const tree = nativeTree([{ file_id: 'stable', path: '中文/笔记.md', hash: 'h', revision: 2, deleted: false }])
expect(tree[0]?.children?.[0]).toMatchObject({ id: 'stable', note_id: 'stable', path: '/中文/笔记.md' })
})
it('保存使用原始内存基线摘要且不调用 HTTP', async () => {
native.enabled = true
native.invoke.mockResolvedValue({})
await workspace.saveFileContent('/中文.md', 'new', 'old')
expect(native.invoke).toHaveBeenCalledWith('workspace_write', { path: '中文.md', content: 'new', expected: await contentHash('old') })
await expect(workspace.saveFileContent('/中文.md', 'new')).rejects.toThrow('EXPECTED_REVISION_REQUIRED')
})
it('冲突保留结构化错误,不变成保存成功', async () => {
native.enabled = true; native.invoke.mockRejectedValue('REVISION_CONFLICT')
await expect(hostInvoke('workspace_write')).rejects.toBeInstanceOf(DesktopError)
})
it('取消原生目录选择不进入空 Vault', async () => {
native.enabled = true; native.invoke.mockResolvedValue(null)
await expect(workspace.openVault('ignored')).rejects.toThrow()
})
})
+47
View File
@@ -0,0 +1,47 @@
/** 平台能力集中检测;Web 模式永不回退到虚构的原生数据。 */
import { invoke, isTauri } from '@tauri-apps/api/core'
import type { FileNode } from '@/contracts'
export const isDesktop = () => isTauri()
export interface HostEntry { file_id: string; path: string; hash: string; revision: number; deleted: boolean; is_folder?: boolean }
export interface HostDocument extends HostEntry { content: string }
export interface HostVault { vault_id: string; path: string; name: string }
export interface HostCapabilities { protocol: number; workspace: boolean; core: boolean; sync: boolean; credentials: boolean; extensions: boolean; release: string }
export class DesktopError extends Error {
constructor(public code: string) { super(code); this.name = 'DesktopError' }
}
export async function hostInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!isDesktop()) throw new DesktopError('DESKTOP_UNAVAILABLE')
try { return await invoke<T>(command, args) }
catch (error) { throw new DesktopError(typeof error === 'string' ? error : 'HOST_ERROR') }
}
export function nativePath(path: string) { return path.replace(/^\//, '') }
export function nativeTree(entries: HostEntry[]): FileNode[] {
const roots: FileNode[] = []
const folders = new Map<string, FileNode>()
for (const entry of entries) {
if (entry.deleted) continue
const parts = entry.path.split('/')
let children = roots, path = ''
for (const name of (entry.is_folder ? parts : parts.slice(0, -1))) {
path += `/${name}`
let node = folders.get(path)
if (!node) {
node = { id: `folder:${path}`, path, name, type: 'folder', children: [] }
folders.set(path, node); children.push(node)
}
children = node.children!
}
if (!entry.is_folder) children.push({ id: entry.file_id, note_id: entry.file_id, path: `/${entry.path}`, name: parts.at(-1)!, type: 'file' })
}
return roots
}
export async function contentHash(content: string) {
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content))))
.map(value => value.toString(16).padStart(2, '0')).join('')
}
@@ -0,0 +1,24 @@
/** 原生命令复用活动编辑器边界;保存失败时保持窗口及内存内容。 */
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand, updateNativeEditorMenu } from '@/services/editorCommandService'
import { watch } from 'vue'
import { isDesktop } from './desktop'
export async function installDesktopLifecycle() {
if (!isDesktop()) return
const activeEditor = useEditorStore()
watch(() => [activeEditor.currentFilePath, activeEditor.saveStatus, activeEditor.mode], updateNativeEditorMenu, { flush: 'post' })
await listen<string>('editor-command', event => { void executeEditorCommand(event.payload) })
let closing = false
await listen('host-close-requested', async () => {
if (closing) return
closing = true
try {
const editor = useEditorStore()
if (['dirty', 'saving', 'save_failed'].includes(editor.saveStatus)) await editor.save()
if (['saved', 'idle'].includes(editor.saveStatus)) await getCurrentWindow().destroy()
} finally { closing = false }
})
}