feat(desktop): 增加原生 Vault 写入与属性导入
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
||||
import { isDesktop } from './platform/desktop'
|
||||
|
||||
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
||||
@@ -27,6 +28,7 @@ export class ApiErrorClass extends Error {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
if (isDesktop()) throw new ApiErrorClass('CORE_UNAVAILABLE', '桌面 AI Core 尚未接通;本地编辑可继续。')
|
||||
const { params, token, headers, timeoutMs, ...rest } = options
|
||||
const controller = timeoutMs ? new AbortController() : null
|
||||
let timedOut = false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Versioned frontend boundary for future native menus/shortcuts; no Tauri IPC yet. */
|
||||
/** 活动编辑器命令边界;原生菜单复用能力检测和处理器。 */
|
||||
import { hostInvoke, isDesktop } from './platform/desktop'
|
||||
export const editorCommandVersion = 1
|
||||
export const editorCommandIds = [
|
||||
'editor.bold', 'editor.italic', 'editor.strikethrough', 'editor.inline-code',
|
||||
@@ -19,7 +20,11 @@ let active: Target | undefined
|
||||
|
||||
export function registerEditorCommands(target: Target) {
|
||||
active = target
|
||||
return () => { if (active === target) active = undefined }
|
||||
updateNativeEditorMenu()
|
||||
return () => { if (active === target) { active = undefined; updateNativeEditorMenu() } }
|
||||
}
|
||||
export function updateNativeEditorMenu() {
|
||||
if (isDesktop()) void hostInvoke('editor_capabilities', { importEnabled: !!active?.handlers['editor.import-note-properties'] && active.available() }).catch(() => undefined)
|
||||
}
|
||||
export function getEditorCommandCapabilities() {
|
||||
return editorCommandIds.map(id => ({ id, supported: !!active?.handlers[id], enabled: !!active?.handlers[id] && active.available() }))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import apiClient from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
import * as noteService from './noteService'
|
||||
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
@@ -80,15 +81,28 @@ async function requireNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||
if (isDesktop()) {
|
||||
const vault = (await getRecentVaults())[0]
|
||||
if (!vault) throw new Error('VAULT_NOT_OPEN')
|
||||
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
|
||||
return { ...vault, file_count: entries.length, indexed_note_count: 0, requires_refresh: false }
|
||||
}
|
||||
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
|
||||
}
|
||||
|
||||
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
if (isDesktop()) return hostInvoke<HostVault[]>('workspace_recent')
|
||||
const workspace = await getWorkspaceInfo()
|
||||
return [{ vault_id: workspace.vault_id, path: workspace.path, name: workspace.name }]
|
||||
}
|
||||
|
||||
export async function openVault(path: string): Promise<VaultInfo> {
|
||||
if (isDesktop()) {
|
||||
const vault = await hostInvoke<HostVault | null>('workspace_choose')
|
||||
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
|
||||
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
|
||||
return vault
|
||||
}
|
||||
treeRequestVersion++
|
||||
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
|
||||
cacheEntries(snapshot.items)
|
||||
@@ -107,6 +121,14 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
|
||||
|
||||
export async function refreshTree(): Promise<FileNode[]> {
|
||||
const version = ++treeRequestVersion
|
||||
if (isDesktop()) {
|
||||
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
|
||||
if (version !== treeRequestVersion) return cachedTree ?? []
|
||||
noteIdByPath.clear(); typeByPath.clear()
|
||||
for (const entry of entries) { if (!entry.is_folder) noteIdByPath.set(`/${entry.path}`, entry.file_id); typeByPath.set(`/${entry.path}`, entry.is_folder ? 'folder' : 'file') }
|
||||
cachedTree = nativeTree(entries)
|
||||
return cachedTree
|
||||
}
|
||||
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree', { timeoutMs: 10000 })
|
||||
if (version !== treeRequestVersion) return cachedTree ?? []
|
||||
return cacheEntries(entries)
|
||||
@@ -117,6 +139,7 @@ export async function getFileTree(): Promise<FileNode[]> {
|
||||
}
|
||||
|
||||
export async function readFileContent(filePath: string): Promise<string> {
|
||||
if (isDesktop()) return (await hostInvoke<HostDocument>('workspace_read', { path: nativePath(filePath) })).content
|
||||
const note = await noteService.getNote(await requireNoteId(filePath))
|
||||
return note.markdown
|
||||
}
|
||||
@@ -127,6 +150,11 @@ export async function getNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string, expectedContent?: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
if (expectedContent === undefined) throw new Error('EXPECTED_REVISION_REQUIRED')
|
||||
await hostInvoke('workspace_write', { path: nativePath(filePath), expected: await contentHash(expectedContent), content })
|
||||
return
|
||||
}
|
||||
const metadata = splitNoteMetadata(content)
|
||||
const expectedHash = expectedContent === undefined ? undefined : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedContent)))).map(byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
await noteService.updateNote(await requireNoteId(filePath), {
|
||||
@@ -142,6 +170,12 @@ export async function createFile(
|
||||
name: string,
|
||||
content = '',
|
||||
): Promise<FileNode> {
|
||||
if (isDesktop()) {
|
||||
const path = [nativePath(folderPath), name.endsWith('.md') ? name : `${name}.md`].filter(Boolean).join('/')
|
||||
const entry = await hostInvoke<HostEntry>('workspace_write', { path, expected: '', content })
|
||||
noteIdByPath.set(`/${path}`, entry.file_id)
|
||||
return { id: entry.file_id, note_id: entry.file_id, path: `/${path}`, name: path.split('/').at(-1)!, type: 'file' }
|
||||
}
|
||||
const title = name.replace(/\.md$/i, '')
|
||||
const note = await noteService.createNote({
|
||||
title,
|
||||
@@ -152,6 +186,11 @@ export async function createFile(
|
||||
}
|
||||
|
||||
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
|
||||
if (isDesktop()) {
|
||||
const path = [nativePath(parentPath), name].filter(Boolean).join('/')
|
||||
await hostInvoke('workspace_mkdir', { path })
|
||||
return { id: `folder:/${path}`, path: `/${path}`, name, type: 'folder', children: [] }
|
||||
}
|
||||
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
|
||||
parent: relativePath(parentPath),
|
||||
name,
|
||||
@@ -160,6 +199,12 @@ export async function createFolder(parentPath: string, name: string): Promise<Fi
|
||||
}
|
||||
|
||||
export async function renameFile(oldPath: string, newName: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const path = nativePath(oldPath)
|
||||
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
||||
await hostInvoke('workspace_rename', { path, destination: [...path.split('/').slice(0, -1), newName].join('/'), expected: document.hash })
|
||||
await refreshTree(); return
|
||||
}
|
||||
const path = normalizePublicPath(oldPath)
|
||||
if (typeByPath.get(path) === 'folder') {
|
||||
await apiClient.post('/api/workspace/folders/rename', {
|
||||
@@ -173,6 +218,12 @@ export async function renameFile(oldPath: string, newName: string): Promise<void
|
||||
}
|
||||
|
||||
export async function deleteFile(pathValue: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const path = nativePath(pathValue)
|
||||
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
||||
await hostInvoke('workspace_delete', { path, expected: document.hash })
|
||||
await refreshTree(); return
|
||||
}
|
||||
const path = normalizePublicPath(pathValue)
|
||||
if (typeByPath.get(path) === 'folder') {
|
||||
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
|
||||
@@ -185,6 +236,12 @@ export async function deleteFile(pathValue: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const path = nativePath(sourcePath)
|
||||
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
||||
await hostInvoke('workspace_rename', { path, destination: [nativePath(targetPath), path.split('/').at(-1)].filter(Boolean).join('/'), expected: document.hash })
|
||||
await refreshTree(); return
|
||||
}
|
||||
const source = normalizePublicPath(sourcePath)
|
||||
if (typeByPath.get(source) !== 'file') {
|
||||
throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
|
||||
|
||||
Reference in New Issue
Block a user